Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/plugins/plugin-approvals/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/service-automation": "workspace:*",
"@objectstack/trigger-record-change": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,22 @@
* the engine decides by-id vs `updateMany`, seeds the AST and builds the hook
* context — so it is the engine, not a fake, that hands the hook a write with
* no id. Same three lines as the issue's repro.
*
* Backend note (#5704 批次 3 / #5785): the store under that engine was a
* hand-written Map + a hand-written `matches()` until this file was migrated to
* `@objectstack/driver-sql` + better-sqlite3 `:memory:`. The file called itself
* an integration test while the half that decides whether `$in`/`$and` select
* the right rows was fixture code written by the same author as the assertion —
* see the deleted stub's own comment ("a fixture that silently matched
* everything would prove the opposite of what these tests claim"), which is a
* hazard only a hand-written matcher has. The predicates are now compiled and
* executed by the SQL builder, so the test proves the lock against the engine
* production runs on.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { bindApprovalLockHook } from './lifecycle-hooks.js';

const opportunity = {
Expand Down Expand Up @@ -49,79 +61,18 @@ const approvalRequest = {
};

/**
* A memory driver that understands the operators this path actually binds:
* `$in` (the caller's predicate) and `$and` (the lock's intersection of that
* predicate with the locked ids). An unknown operator matches nothing rather
* than comparing an object to a scalar — a fixture that silently matched
* everything would prove the opposite of what these tests claim.
* The real backend: better-sqlite3 `:memory:` through `@objectstack/driver-sql`,
* constructed the way the rest of the repo constructs an ephemeral store
* (`examples/app-crm`, `cli db clean`, PR #5715's `makeDefaultDriver()`). The
* database lives and dies inside the process, so each `beforeEach` starts on a
* genuinely empty schema with nothing on the host filesystem.
*/
function makeMemoryDriver() {
const stores = new Map<string, Map<string, Record<string, unknown>>>();
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
let nextId = 0;
const matches = (row: Record<string, unknown>, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
if (k === '$and') { if (!(v as any[]).every((w) => matches(row, w))) return false; continue; }
if (k === '$or') { if (!(v as any[]).some((w) => matches(row, w))) return false; continue; }
if (v && typeof v === 'object' && !Array.isArray(v)) {
if ('$in' in (v as any)) {
if (!(v as any).$in.map(String).includes(String(row[k]))) return false;
continue;
}
if ('$eq' in (v as any)) {
if ((row[k] ?? null) !== ((v as any).$eq ?? null)) return false;
continue;
}
return false;
}
if ((row[k] ?? null) !== (v ?? null)) return false;
}
return true;
};
const driver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
async create(o: string, data: Record<string, unknown>) {
nextId += 1;
const id = (data.id as string) ?? `r_${nextId}`;
const row = { ...data, id };
storeFor(o).set(id, row);
return row;
},
async update(o: string, id: string, data: Record<string, unknown>) {
const s = storeFor(o);
const cur = s.get(id);
if (!cur) throw new Error(`nf ${o}/${id}`);
const up = { ...cur, ...data, id };
s.set(id, up);
return up;
},
async updateMany(o: string, ast: any, data: Record<string, unknown>) {
const s = storeFor(o);
const hits = Array.from(s.values()).filter((r) => matches(r, ast?.where));
for (const r of hits) s.set(String(r.id), { ...r, ...data, id: r.id });
return hits.length;
},
async upsert(o: string, data: Record<string, unknown>) {
const id = data.id as string | undefined;
return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data);
},
async delete(o: string, id: string) { return storeFor(o).delete(id); },
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
async bulkUpdate() { return []; },
async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return driver;
function makeSqliteDriver() {
return new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
}

const USER_CTX = { isSystem: false, userId: 'u1', positions: [], permissions: [] };
Expand All @@ -147,11 +98,20 @@ describe('approvals record lock — predicate (multi) updates (#4778)', () => {
}, { context: { isSystem: true } } as any);
};

afterEach(async () => {
// `:memory:` dies with the connection; closing it keeps the pool from
// accumulating one live database per test in a file this size.
try { await engine?.destroy(); } catch { /* noop */ }
});

beforeEach(async () => {
engine = new ObjectQL();
engine.registerDriver(makeMemoryDriver(), true);
engine.registerDriver(makeSqliteDriver(), true);
await engine.init();
for (const o of [opportunity, approvalRequest]) engine.registry.registerObject(o as any);
// Real DDL through the real path — the tables every write below hits are
// created by the driver, not conjured by a store on first write.
await engine.syncSchemas();

lockedId = String((await engine.insert('opportunity', { name: 'Deal', amount: 100 })).id);
freeId = String((await engine.insert('opportunity', { name: 'Other', amount: 100 })).id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@
* `resolveRunDataContext` from the automation runtime, feeds its output to a
* real {@link ObjectQL} engine, and lets the real lock hook decide — the same
* three layers, in the same order, as a live deployment.
*
* Backend note (#5704 批次 3 / #5785): the layer under the engine was a
* hand-written Map store until this file was migrated to
* `@objectstack/driver-sql` + better-sqlite3 `:memory:`. A test whose whole
* argument is "no hop is stubbed" cannot leave the storage hop stubbed; the
* write the exemption lets through is now a real UPDATE against a real table.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { resolveRunDataContext } from '@objectstack/service-automation';
import { bindApprovalLockHook } from './lifecycle-hooks.js';

Expand Down Expand Up @@ -53,56 +60,16 @@ const approvalRequest = {
},
};

function makeMemoryDriver() {
const stores = new Map<string, Map<string, Record<string, unknown>>>();
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
let nextId = 0;
const matches = (row: Record<string, unknown>, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
if (k.startsWith('$')) continue;
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
if ((row[k] ?? null) !== (exp ?? null)) return false;
}
return true;
};
const driver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
async create(o: string, data: Record<string, unknown>) {
nextId += 1;
const id = (data.id as string) ?? `r_${nextId}`;
const row = { ...data, id };
storeFor(o).set(id, row);
return row;
},
async update(o: string, id: string, data: Record<string, unknown>) {
const s = storeFor(o);
const cur = s.get(id);
if (!cur) throw new Error(`nf ${o}/${id}`);
const up = { ...cur, ...data, id };
s.set(id, up);
return up;
},
async upsert(o: string, data: Record<string, unknown>) {
const id = data.id as string | undefined;
return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data);
},
async delete(o: string, id: string) { return storeFor(o).delete(id); },
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
async bulkUpdate() { return []; },
async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return driver;
/**
* The real backend: better-sqlite3 `:memory:` through `@objectstack/driver-sql`,
* built the canonical way (`examples/app-crm`, `cli db clean`, PR #5715).
*/
function makeSqliteDriver() {
return new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
}

/**
Expand All @@ -122,11 +89,16 @@ describe('an owning run and the approvals record lock (#3703 / #3712 / #3760)',
let engine: ObjectQL;
let oppId: string;

afterEach(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
});

beforeEach(async () => {
engine = new ObjectQL();
engine.registerDriver(makeMemoryDriver(), true);
engine.registerDriver(makeSqliteDriver(), true);
await engine.init();
for (const o of [opportunity, approvalRequest]) engine.registry.registerObject(o as any);
await engine.syncSchemas(); // real DDL for both tables

const opp = await engine.insert('opportunity', { name: 'Deal', amount: 100 });
oppId = String(opp.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,18 @@
*
* The record lock is deliberately not bound here: it is a separate concern with
* its own end-to-end coverage (`record-lock-schedule-run.integration.test.ts`).
*
* Backend note (#5704 批次 3 / #5785): "refuses to stub any of them" used to
* stop one layer short — the store was a hand-written Map behind the real
* kernel. It is now `@objectstack/driver-sql` + better-sqlite3 `:memory:`, so
* the mirror write, the cascading flow's `update_record`, and the audit stamp
* this file reads back all land in a real table.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ObjectKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
import { ApprovalService } from './approval-service.js';
Expand All @@ -45,68 +52,16 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const SUBMITTER = { userId: 'submitter', positions: [], permissions: [] } as any;
const APPROVER = { userId: 'approver', positions: [], permissions: [] } as any;

/** Equality-WHERE in-memory driver — the same shape the trigger's own e2e uses. */
function makeMemoryDriver(): any {
const stores = new Map<string, Map<string, Record<string, unknown>>>();
const storeFor = (obj: string) => {
let s = stores.get(obj);
if (!s) { s = new Map(); stores.set(obj, s); }
return s;
};
let nextId = 0;
const matches = (row: Record<string, unknown>, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matches(row, w));
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matches(row, w));
for (const [k, v] of Object.entries(where)) {
if (k.startsWith('$')) continue;
const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
const a = row[k] === undefined ? null : row[k];
const b = expected === undefined ? null : expected;
if (a !== b) return false;
}
return true;
};
return {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; }, async syncSchema() {},
async find(object: string, ast: any) {
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
},
async findOne(object: string, ast: any) {
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
return null;
},
async create(object: string, data: Record<string, unknown>) {
nextId += 1;
const id = (data.id as string) ?? `r_${nextId}`;
const row = { ...data, id };
storeFor(object).set(id, row);
return row;
},
async update(object: string, id: string, data: Record<string, unknown>) {
const s = storeFor(object);
const cur = s.get(id);
if (!cur) throw new Error(`not found: ${object}/${id}`);
const updated = { ...cur, ...data, id };
s.set(id, updated);
return updated;
},
async upsert(object: string, data: Record<string, unknown>) {
const id = data.id as string | undefined;
if (id && storeFor(object).has(id)) return this.update(object, id, data);
return this.create(object, data);
},
async delete(object: string, id: string) { return storeFor(object).delete(id); },
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
return Promise.all(rows.map((r) => this.create(object, r)));
},
async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
/**
* The real backend: better-sqlite3 `:memory:` through `@objectstack/driver-sql`,
* built the canonical way (`examples/app-crm`, `cli db clean`, PR #5715).
*/
function makeSqliteDriver() {
return new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
}

const opportunity = {
Expand Down Expand Up @@ -161,6 +116,11 @@ const nodeConfig = {
describe('an approval decision cascades as the deciding user (#3783)', () => {
let data: any;
let svc: ApprovalService;
let engine: any;

afterEach(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
});

beforeEach(async () => {
const kernel = new ObjectKernel({ logLevel: 'silent' });
Expand All @@ -170,13 +130,21 @@ describe('an approval decision cascades as the deciding user (#3783)', () => {
await kernel.bootstrap();

const objectql = kernel.getService('objectql') as any;
engine = objectql;
data = kernel.getService('data') as any;
const automation = kernel.getService<AutomationEngine>('automation');

objectql.registerDriver(makeMemoryDriver(), true);
// The engine's own `init()` ran during bootstrap, before this driver
// existed, so the connect the engine would have done is done here.
const driver = makeSqliteDriver();
await driver.connect();
objectql.registerDriver(driver, true);
for (const def of [opportunity, SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
objectql.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
}
// Real DDL for all four objects — including the three sys_approval_* tables
// the ApprovalService writes through.
await objectql.syncSchemas();
automation.registerFlow('on_approved', onApprovedFlow as any);

svc = new ApprovalService({ engine: objectql });
Expand Down
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
Expand Down
Loading
Loading