diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index f493c9d27b..93bcab0987 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -26,6 +26,7 @@ "@objectstack/types": "workspace:*" }, "devDependencies": { + "@objectstack/driver-sql": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/service-automation": "workspace:*", "@objectstack/trigger-record-change": "workspace:*", diff --git a/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts index e0dfcd000f..19241ee376 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts @@ -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 = { @@ -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>>(); - 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, 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) { - 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) { - 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) { - 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) { - 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[]) { 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: [] }; @@ -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); diff --git a/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts index b2582d7d3d..957170b887 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts @@ -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'; @@ -53,56 +60,16 @@ const approvalRequest = { }, }; -function makeMemoryDriver() { - const stores = new Map>>(); - 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, 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) { - 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) { - 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) { - 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[]) { 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, + }); } /** @@ -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); diff --git a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts index b2af91de47..fbf0390bd0 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts @@ -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'; @@ -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>>(); - 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, 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) { - 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) { - 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) { - 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[]) { - 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 = { @@ -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' }); @@ -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('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 }); diff --git a/packages/rest/package.json b/packages/rest/package.json index c4138ad002..16a96d4221 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -29,6 +29,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@objectstack/driver-sql": "workspace:*", "@objectstack/metadata": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 93793b5162..cf2eb886db 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -3,7 +3,8 @@ /** * End-to-end export integration: the REAL streaming export route driven by a * REAL {@link ObjectQL} engine + {@link ObjectStackProtocolImplementation}, - * an in-memory driver, and real registered objects — no protocol mocks. + * a REAL sqlite `:memory:` driver, and real registered objects — no protocol + * mocks and, since #5704 批次 3 / #5785, no hand-written storage either. * * This is the test the mocked `rest.test.ts` export suite could not be: those * stubbed `getObjectSchema` (a method with no real implementation) and pre-shaped @@ -19,103 +20,45 @@ * Here the readable cells (完成→是, 优先级→高, 负责人→张三) are produced by the * real metadata accessor (`getMetaItem`) and a real `$expand` that resolves the * lookup id `u1` to its record — exactly the path a deployed server runs. + * + * Backend note (#5704 批次 3 / #5785): the store was a hand-written Map with a + * hand-written `matches()` / `sortRows()` until this file moved to + * `@objectstack/driver-sql` + better-sqlite3 `:memory:`. The stub's own comments + * record how narrow that ledge was — it had to learn `$or`/`$and` after + * "skipping them silently returned every row", and `$contains` after a search + * predicate turned out to be a no-op that still passed an "it filtered" + * assertion. Every one of those is a class of bug a fixture matcher can have and + * the production engine cannot; filter, sort, paging and `$expand` are now + * compiled to SQL by the driver the deployed server uses. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import ExcelJS from 'exceljs'; import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; // --------------------------------------------------------------------------- -// In-memory driver — equality + `$in` (the latter is what `$expand` issues when -// it batch-fetches referenced records: `where: { id: { $in: [...] } }`). +// The real backend: better-sqlite3 `:memory:`, constructed the canonical way +// (`examples/app-crm`, `cli db clean`, PR #5715's `makeDefaultDriver()`). // --------------------------------------------------------------------------- -function makeMemoryDriver() { - const stores = new Map>>(); - const storeFor = (o: string) => { - let s = stores.get(o); - if (!s) { s = new Map(); stores.set(o, s); } - return s; - }; - let nextId = 0; - const matchOne = (cell: unknown, cond: unknown): boolean => { - if (cond && typeof cond === 'object' && !Array.isArray(cond)) { - const c = cond as Record; - if ('$in' in c) return Array.isArray(c.$in) && c.$in.some((x) => (cell ?? null) === (x ?? null)); - if ('$eq' in c) return (cell ?? null) === ((c.$eq as unknown) ?? null); - if ('$ne' in c) return (cell ?? null) !== ((c.$ne as unknown) ?? null); - // `$search` folds to `{ $or: [{ field: { $contains: term } }] }`, so the - // driver must understand `$contains` or a search predicate is a no-op and - // an "it filtered" assertion passes for the wrong reason. - if ('$contains' in c) return String(cell ?? '').includes(String(c.$contains ?? '')); - } - return (cell ?? null) === ((cond as unknown) ?? null); - }; - const matches = (row: Record, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - for (const [k, v] of Object.entries(where)) { - // Logical nodes — the shape `$search` and a composed `filter` produce. - // Skipping them (as this driver used to) silently returns every row. - if (k === '$or') { if (!(Array.isArray(v) && v.some((sub) => matches(row, sub)))) return false; continue; } - if (k === '$and') { if (!(Array.isArray(v) && v.every((sub) => matches(row, sub)))) return false; continue; } - if (k.startsWith('$')) continue; - if (!matchOne(row[k], v)) return false; - } - return true; - }; - const sortRows = (rows: Record[], orderBy: any): Record[] => { - if (!orderBy) return rows; - // Accept {field:'asc'|'desc'} | [['field','asc']] | ['field'] - const specs: Array<[string, 'asc' | 'desc']> = []; - if (Array.isArray(orderBy)) { - for (const o of orderBy) { - if (Array.isArray(o)) specs.push([String(o[0]), o[1] === 'desc' ? 'desc' : 'asc']); - else if (typeof o === 'string') specs.push([o, 'asc']); - } - } else if (typeof orderBy === 'object') { - for (const [f, d] of Object.entries(orderBy)) specs.push([f, d === 'desc' ? 'desc' : 'asc']); - } - if (specs.length === 0) return rows; - return [...rows].sort((a, b) => { - for (const [f, d] of specs) { - const av = a[f] as any, bv = b[f] as any; - if (av === bv) continue; - const cmp = av < bv ? -1 : 1; - return d === 'desc' ? -cmp : cmp; - } - return 0; - }); - }; - 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) { - const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); - const sorted = sortRows(rows, ast?.orderBy ?? ast?.sort ?? ast?.order); - const skip = Number(ast?.skip ?? ast?.offset ?? 0) || 0; - const limit = ast?.limit ?? ast?.top; - const sliced = limit != null ? sorted.slice(skip, skip + Number(limit)) : sorted.slice(skip); - return sliced; - }, - 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) { - 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) { - 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) { 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[]) { 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, stores }; +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); } +/** Engines booted by this file, torn down (and their `:memory:` DBs closed) per test. */ +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + // --------------------------------------------------------------------------- // Objects — object-map `fields` (the engine's real shape), mixed value types. // systemFields:false keeps the column set deterministic (just our fields). @@ -181,12 +124,15 @@ function makeBinRes() { } async function boot() { - const { driver } = makeMemoryDriver(); const engine = new ObjectQL(); - engine.registerDriver(driver, true); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); await engine.init(); engine.registry.registerObject(USER as any); engine.registry.registerObject(TASK as any); + // Real DDL through the real path — `user` and `task` are physical tables + // before a single row is written. + await engine.syncSchemas(); await engine.insert('user', { id: 'u1', name: '张三' }); await engine.insert('user', { id: 'u2', name: '李四' }); // owner stored as a bare id — the readable name must come from a real $expand. @@ -409,12 +355,13 @@ describe('export route — FLS column projection via getReadableFields (#3547)', getReadableFields: (object: string, context?: any) => string[] | undefined; tasks?: Array>; }) { - const { driver } = makeMemoryDriver(); const engine = new ObjectQL(); - engine.registerDriver(driver, true); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); await engine.init(); engine.registry.registerObject(USER as any); engine.registry.registerObject(TASK as any); + await engine.syncSchemas(); await engine.insert('user', { id: 'u1', name: '张三' }); const tasks = opts.tasks ?? [ { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }, diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index d0d768d2a0..6fa4fd8ad9 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -3,75 +3,51 @@ /** * End-to-end import integration: the REAL `POST /data/:object/import` route * driven by a REAL {@link ObjectQL} engine + {@link ObjectStackProtocolImplementation}, - * an in-memory driver, and real registered objects — no protocol mocks. + * a REAL sqlite `:memory:` driver, and real registered objects — no protocol + * mocks and, since #5704 批次 3 / #5785, no hand-written storage either. * * Mirrors `export-integration.test.ts`. It proves the server-side coercion + * upsert pipeline against the SAME metadata accessor (`getMetaItem`) and write * path (`createData`/`updateData`) a deployed server runs: human cells * (是→true, 高→high, name→id) become storage values, and writeMode routes each * row to create / update / skip. + * + * Backend note (#5704 批次 3 / #5785): the store was a hand-written Map until + * this file moved to `@objectstack/driver-sql` + better-sqlite3 `:memory:`. + * "Human cell becomes a storage value" is the whole claim of this file, and a + * Map stores whatever JavaScript value it is handed — `true` stays `true`, + * `['u1','u2']` stays an array, a number stays a number, no column ever + * disagrees. The point of the migration is that the round trip now goes through + * real column types: 是 is coerced to a boolean, written as SQLite's integer 1, + * and read back as `true`. */ -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 { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; // --------------------------------------------------------------------------- -// In-memory driver — equality + `$in` (what matchFields / $expand issue). +// The real backend: better-sqlite3 `:memory:`, constructed the canonical way +// (`examples/app-crm`, `cli db clean`, PR #5715's `makeDefaultDriver()`). // --------------------------------------------------------------------------- -function makeMemoryDriver() { - const stores = new Map>>(); - const storeFor = (o: string) => { - let s = stores.get(o); - if (!s) { s = new Map(); stores.set(o, s); } - return s; - }; - let nextId = 0; - const matchOne = (cell: unknown, cond: unknown): boolean => { - if (cond && typeof cond === 'object' && !Array.isArray(cond)) { - const c = cond as Record; - if ('$in' in c) return Array.isArray(c.$in) && c.$in.some((x) => (cell ?? null) === (x ?? null)); - if ('$eq' in c) return (cell ?? null) === ((c.$eq as unknown) ?? null); - if ('$ne' in c) return (cell ?? null) !== ((c.$ne as unknown) ?? null); - } - return (cell ?? null) === ((cond as unknown) ?? null); - }; - const matches = (row: Record, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - for (const [k, v] of Object.entries(where)) { - if (k.startsWith('$')) continue; - if (!matchOne(row[k], v)) 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) { - const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); - const skip = Number(ast?.skip ?? ast?.offset ?? 0) || 0; - const limit = ast?.limit ?? ast?.top; - return limit != null ? rows.slice(skip, skip + Number(limit)) : rows.slice(skip); - }, - 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) { - 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) { - 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) { 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[]) { 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, stores }; +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); } +/** Engines booted by this file, torn down (and their `:memory:` DBs closed) per test. */ +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + const USER = { name: 'user', label: 'User', systemFields: false, fields: { @@ -146,13 +122,16 @@ function makeRes() { } async function boot() { - const { driver } = makeMemoryDriver(); const engine = new ObjectQL(); - engine.registerDriver(driver, true); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); await engine.init(); engine.registry.registerObject(USER as any); engine.registry.registerObject(TASK as any); engine.registry.registerObject(MEMBER as any); + // Real DDL — the NOT NULL / column types the importer's dry run claims to + // predict are now physically there to be violated. + await engine.syncSchemas(); await engine.insert('user', { id: 'u1', name: '张三', email: 'zhang@x.com' }); await engine.insert('user', { id: 'u2', name: '李四', email: 'li@x.com' }); diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 80990c0713..709612436a 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -3,73 +3,48 @@ /** * End-to-end async import-job integration: the REAL create / progress / results * / list / cancel routes driven by a REAL {@link ObjectQL} engine + - * {@link ObjectStackProtocolImplementation}, an in-memory driver, and real - * registered objects (including a `sys_import_job` mirror) — no protocol mocks. + * {@link ObjectStackProtocolImplementation}, a REAL sqlite `:memory:` driver, + * and real registered objects (including the platform's own `sys_import_job`) — + * no protocol mocks and, since #5704 批次 3 / #5785, no hand-written storage and + * no hand-written mirror of the job object either. * * Proves the P1 async pipeline: a create request persists a job row and returns * immediately; the background worker streams the batch through the SAME shared * runner the sync route uses, updating progress on the row; and readers can poll * progress, fetch a capped results report, and list history. + * + * Backend note (#5704 批次 3 / #5785): "persists a job row" was, until this + * migration, an entry in a Map — durability asserted against a store that cannot + * fail to store. The job row now round-trips through a real table, which is what + * makes the out-of-band-cancel case (a `status` another node wrote, read back by + * this one) a statement about persisted state rather than about shared memory. */ -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 { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysImportJob } from '@objectstack/platform-objects/audit'; import { RestServer } from './rest-server'; -// In-memory driver — equality + `$in`, with skip/limit (mirrors import-integration). -function makeMemoryDriver() { - const stores = new Map>>(); - const storeFor = (o: string) => { - let s = stores.get(o); - if (!s) { s = new Map(); stores.set(o, s); } - return s; - }; - let nextId = 0; - const matchOne = (cell: unknown, cond: unknown): boolean => { - if (cond && typeof cond === 'object' && !Array.isArray(cond)) { - const c = cond as Record; - if ('$in' in c) return Array.isArray(c.$in) && c.$in.some((x) => (cell ?? null) === (x ?? null)); - if ('$eq' in c) return (cell ?? null) === ((c.$eq as unknown) ?? null); - if ('$ne' in c) return (cell ?? null) !== ((c.$ne as unknown) ?? null); - } - return (cell ?? null) === ((cond as unknown) ?? null); - }; - const matches = (row: Record, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - for (const [k, v] of Object.entries(where)) { - if (k.startsWith('$')) continue; - if (!matchOne(row[k], v)) 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) { - const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); - const skip = Number(ast?.skip ?? ast?.offset ?? 0) || 0; - const limit = ast?.limit ?? ast?.top; - return limit != null ? rows.slice(skip, skip + Number(limit)) : rows.slice(skip); - }, - 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) { - 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) { - 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) { 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[]) { 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, stores }; +// The real backend: better-sqlite3 `:memory:`, constructed the canonical way +// (`examples/app-crm`, `cli db clean`, PR #5715's `makeDefaultDriver()`). +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); } +/** Engines booted by this file, torn down (and their `:memory:` DBs closed) per test. */ +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + const TASK = { name: 'task', label: 'Task', systemFields: false, fields: { @@ -80,31 +55,22 @@ const TASK = { }, }; -// Minimal sys_import_job mirror the routes read/write through the protocol. -const SYS_IMPORT_JOB = { - name: 'sys_import_job', label: 'Import Job', systemFields: false, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - object_name: { name: 'object_name', type: 'text' as const }, - status: { name: 'status', type: 'text' as const }, - total_rows: { name: 'total_rows', type: 'number' as const }, - processed_rows: { name: 'processed_rows', type: 'number' as const }, - created_count: { name: 'created_count', type: 'number' as const }, - updated_count: { name: 'updated_count', type: 'number' as const }, - skipped_count: { name: 'skipped_count', type: 'number' as const }, - error_count: { name: 'error_count', type: 'number' as const }, - write_mode: { name: 'write_mode', type: 'text' as const }, - dry_run: { name: 'dry_run', type: 'boolean' as const }, - run_automations: { name: 'run_automations', type: 'boolean' as const }, - treat_as_historical: { name: 'treat_as_historical', type: 'boolean' as const }, - error: { name: 'error', type: 'textarea' as const }, - results: { name: 'results', type: 'json' as const }, - started_at: { name: 'started_at', type: 'text' as const }, - completed_at: { name: 'completed_at', type: 'text' as const }, - created_by: { name: 'created_by', type: 'text' as const }, - created_at: { name: 'created_at', type: 'text' as const }, - }, -}; +/** + * The REAL `sys_import_job` (`@objectstack/platform-objects`), not a mirror. + * + * This slot held a hand-written "minimal mirror" until #5785. A Map store + * accepted every key the routes wrote whether the fixture declared it or not, so + * the mirror could silently fall behind the object it mirrored — and it had: + * `undo_log` and `reverted_at` (the whole undo feature, #3549's subject) were + * missing, and three datetime columns were declared `text`. Against a real + * table that is `no such column: undo_log`, which is how the drift surfaced. + * + * Re-declaring the columns by hand would only reset the same clock, so the + * fixture is retired in favour of the definition the deployed server registers. + * `@objectstack/platform-objects` is already a production dependency of this + * package, and the routes under test write this object by name. + */ +const SYS_IMPORT_JOB = SysImportJob; function createMockServer() { const noop = () => {}; @@ -122,13 +88,16 @@ function makeRes() { } async function boot(decorateDriver?: (driver: any) => void) { - const { driver } = makeMemoryDriver(); + const driver = makeSqliteDriver(); decorateDriver?.(driver); const engine = new ObjectQL(); + liveEngines.push(engine); engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(TASK as any); engine.registry.registerObject(SYS_IMPORT_JOB as any); + // Real DDL for both tables before the first job row is written. + await engine.syncSchemas(); const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index e95ab110c8..6c36ffc6f2 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -22,6 +22,7 @@ "@objectstack/spec": "workspace:*" }, "devDependencies": { + "@objectstack/driver-sql": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/service-automation": "workspace:*", "@types/node": "^26.1.2", diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index 27d6bf3202..a53b9c5e0c 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -9,85 +9,67 @@ * the trigger binding to an ObjectQL lifecycle hook on `kernel:ready`, an actual * insert firing that hook, and the flow's `update_record` writing back through * the live data engine. This test boots a real kernel (ObjectQL + automation + - * record-change trigger + in-memory driver) and asserts the full chain — in BOTH - * registration orderings, since the engine relies on re-activating already-pulled - * flows when the trigger registers later. + * record-change trigger + a real sqlite `:memory:` driver) and asserts the full + * chain — in BOTH registration orderings, since the engine relies on + * re-activating already-pulled flows when the trigger registers later. + * + * Backend note (#5704 批次 3 / #5785): the driver was a hand-written Map store + * until this file was migrated to `@objectstack/driver-sql` + better-sqlite3 + * `:memory:`. #1491 was precisely a chain that "worked" in every unit test + * because a fake sat where the real component belonged, so leaving the storage + * hop faked was the one shortcut this file could least afford. The concrete + * fidelity it buys here: a column that was never written now reads back as SQL + * NULL out of a table whose DDL really ran, instead of `undefined` out of a Map + * that only ever held keys somebody set. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, 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 './plugin.js'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** - * A tiny equality-WHERE in-memory driver — enough to exercise the real engine's - * insert/update/find path without pulling a driver package as a dependency - * (mirrors objectql's own real-engine test helper). One record store per object. + * The real backend: better-sqlite3 `:memory:` through `@objectstack/driver-sql`, + * built the canonical way (`examples/app-crm`, `cli db clean`, PR #5715). The + * database lives and dies inside the process, so every `it` below gets a fresh + * empty schema without touching the host filesystem. */ -function makeMemoryDriver(): any { - const stores = new Map>>(); - 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, 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) { - 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) { - 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) { - 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[]) { - 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() {}, - }; +function makeSqliteDriver(): any { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +/** Every `:memory:` database opened by a test, closed when that test ends. */ +const openDrivers: any[] = []; +afterEach(async () => { + while (openDrivers.length) { + try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ } + } +}); + +/** + * Register the driver on an engine whose own `init()` already ran during + * `kernel.bootstrap()` (that is what "the driver arrives late" means here), so + * the connect the engine would have performed happens here instead. Each caller + * then registers its objects and calls `syncSchemas()`. + * + * `syncSchemas()` is the production route for objects that become live after + * boot; the Map store needed no counterpart because a collection materialised on + * first write, which is exactly the difference this migration is buying. + */ +async function attachSqlite(objectql: any): Promise { + const driver = makeSqliteDriver(); + await driver.connect(); + objectql.registerDriver(driver, true); + openDrivers.push(driver); + return driver; } /** A flow that stamps `stamp: 'done'` on the just-created record of `object`. */ @@ -208,8 +190,9 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( const data = kernel.getService('data') as any; const automation = kernel.getService('automation'); - objectql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(objectql); objectql.registry.registerObject(objectDef('sysw'), 'test', 'test'); + await objectql.syncSchemas(); // No `runAs` — the spec default 'user'. This is the shape an author (very // often an AI) writes without realising it can run without a user. automation.registerFlow('sysw_stamp', stampFlow('sysw_stamp', 'sysw') as any); @@ -229,8 +212,16 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( // The flow's update_record must NOT have landed. Before #3760 `stamp` was // 'done' here — written by a run with no principal at all. + // + // "Never written" reads as SQL NULL, not `undefined`: the column exists + // because the DDL declared it, and only a write puts a value in it. The Map + // store had no column concept, so an unwritten key was simply absent — the + // one place this migration changes the SHAPE of the answer rather than the + // answer. The property under test is unchanged and is asserted exactly, not + // loosened to `toBeFalsy()`: no value landed, and in particular not 'done'. const row = await data.findOne('sysw', { where: { id } }); - expect(row?.stamp, 'a user-less run wrote to the record — the fail-open is back').toBeUndefined(); + expect(row, 'the record itself must exist — only the flow write is refused').toBeTruthy(); + expect(row?.stamp ?? null, 'a user-less run wrote to the record — the fail-open is back').toBeNull(); }, 15000); it('the same flow still works normally when a real user made the write', async () => { @@ -244,8 +235,9 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( const data = kernel.getService('data') as any; const automation = kernel.getService('automation'); - objectql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(objectql); objectql.registry.registerObject(objectDef('sysw2'), 'test', 'test'); + await objectql.syncSchemas(); automation.registerFlow('sysw2_stamp', stampFlow('sysw2_stamp', 'sysw2') as any); const created = await data.insert('sysw2', { status: 'new' }, { context: { userId: 'u_trigger' } }); @@ -271,8 +263,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { const data = kernel.getService('data') as any; const automation = kernel.getService('automation'); - objectql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid'), 'test', 'test'); + await objectql.syncSchemas(); automation.registerFlow('stamp_flow', stampFlow('stamp_flow', 'wid') as any); // The flow bound to the trigger… @@ -304,9 +297,10 @@ describe('record-change trigger — end-to-end (#1491)', () => { async init() {}, async start(ctx: any) { const ql = ctx.getService('objectql'); - ql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(ql); ql.registry.registerObject(objectDef('wid2'), 'test', 'test'); ql.registry.registerItem('flow', flowDef, 'name', 'test'); + await ql.syncSchemas(); }, }; @@ -345,8 +339,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { const data = kernel.getService('data') as any; const automation = kernel.getService('automation'); - objectql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid3'), 'test', 'test'); + await objectql.syncSchemas(); automation.registerFlow('mirror_write', mirrorWriteFlow('mirror_write', 'wid3') as any); expect((automation as any).getActiveTriggerBindings()).toContainEqual({ @@ -378,8 +373,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { const data = kernel.getService('data') as any; const automation = kernel.getService('automation'); - objectql.registerDriver(makeMemoryDriver(), true); + await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid5'), 'test', 'test'); + await objectql.syncSchemas(); automation.registerFlow('urgent_alert', urgentAlertFlow('urgent_alert', 'wid5') as any); // Create leg — a brand-new URGENT record: `previous == null` makes the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f0d88ee4..b6afbb9bae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1352,6 +1352,9 @@ importers: specifier: workspace:* version: link:../../types devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../drivers/driver-sql '@objectstack/objectql': specifier: workspace:* version: link:../../objectql @@ -1864,6 +1867,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../drivers/driver-sql '@objectstack/metadata': specifier: workspace:* version: link:../metadata @@ -2434,6 +2440,9 @@ importers: specifier: workspace:* version: link:../../spec devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../drivers/driver-sql '@objectstack/objectql': specifier: workspace:* version: link:../../objectql