diff --git a/.changeset/2092-tests-on-sql.md b/.changeset/2092-tests-on-sql.md new file mode 100644 index 00000000..61414bf0 --- /dev/null +++ b/.changeset/2092-tests-on-sql.md @@ -0,0 +1,4 @@ +--- +--- + +Tests only, nothing ships: every test that ran on `@objectstack/driver-memory` now runs on the SQL driver (`driver-sqlite-wasm`), and the app no longer lists `driver-memory` as a dependency of its own (#2092). diff --git a/package.json b/package.json index c6e4bf78..98863efd 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@objectstack/account": "18.0.0-next.0", "@objectstack/cli": "18.0.0-next.0", "@objectstack/core": "18.0.0-next.0", - "@objectstack/driver-memory": "18.0.0-next.0", "@objectstack/driver-sql": "18.0.0-next.0", "@objectstack/driver-sqlite-wasm": "18.0.0-next.0", "@objectstack/metadata": "18.0.0-next.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a48addd..335d81ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@objectstack/core': specifier: 18.0.0-next.0 version: 18.0.0-next.0 - '@objectstack/driver-memory': - specifier: 18.0.0-next.0 - version: 18.0.0-next.0 '@objectstack/driver-sql': specifier: 18.0.0-next.0 version: 18.0.0-next.0 diff --git a/test/activity-seed-coverage.test.ts b/test/activity-seed-coverage.test.ts index 1b74d369..4ae0992d 100644 --- a/test/activity-seed-coverage.test.ts +++ b/test/activity-seed-coverage.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import { resolveSeedRecord } from '@objectstack/formula'; import stack from './helpers/composed-stack'; @@ -407,9 +407,14 @@ const accountIsActiveDefault = (): boolean => { return field!.defaultValue === true; }; -const makeEngine = () => - ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, +/** + * A real SQLite database. `ObjectQL.create` connects the datasource but emits + * no DDL, so the engine's own `syncSchemas()` creates the tables the way the + * runtime's boot does. + */ +const makeEngine = async () => { + const ql = await ObjectQL.create({ + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { // Only the columns the four datasets read. The object suite owns the full // schemas; the contract under test here is "which rows do the widget @@ -460,6 +465,9 @@ const makeEngine = () => }, } as never, }); + await ql.syncSchemas(); + return ql; +}; describe('every Sales Activity widget returns a number over the shipped seeds (#671)', () => { let ql: Awaited>; diff --git a/test/attendee-type-resolution.test.ts b/test/attendee-type-resolution.test.ts index 2e209da2..438b04d5 100644 --- a/test/attendee-type-resolution.test.ts +++ b/test/attendee-type-resolution.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { SysUser } from '@objectstack/platform-objects'; import stack from './helpers/composed-stack'; import { ATTENDEE_RESOLUTIONS } from '../src/sales/objects/event_attendee.object'; @@ -150,7 +150,7 @@ describe('the acceptance surface, on a real engine', () => { beforeEach(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_event_attendee: EVENT_ATTENDEE, crm_event: byName('crm_event'), @@ -160,6 +160,7 @@ describe('the acceptance surface, on a real engine', () => { sys_user: SysUser, } as never, })) as never; + await ql.syncSchemas(); api = ql.createContext({ isSystem: true }); const event = await api.object('crm_event').insert({ diff --git a/test/audit-stamp-readonly.test.ts b/test/audit-stamp-readonly.test.ts index e57cc48b..03477c76 100644 --- a/test/audit-stamp-readonly.test.ts +++ b/test/audit-stamp-readonly.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; @@ -76,7 +76,7 @@ let ql: any; beforeAll(async () => { ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_account: shippedFields('crm_account'), crm_opportunity: shippedFields('crm_opportunity'), @@ -85,6 +85,7 @@ beforeAll(async () => { crm_campaign_member: shippedFields('crm_campaign_member'), } as never, }); + await ql.syncSchemas(); }); afterAll(async () => { @@ -170,9 +171,15 @@ const readBack = (object: string, id: string): Promise => // ─────────────────────────────────────────────────────── #1666 · approval ── describe('#1666 — crm_opportunity approval stamps are declared readonly', () => { - /** A closable deal, inserted under a system context so the seed itself is not the measurement. */ + /** + * A closable deal, inserted under a system context so the seed itself is not + * the measurement. Each deal gets its own account: `crm_account.name` is + * declared unique, and the SQL driver enforces it (`driver-memory`, which ran + * this file until #2092, did not). + */ + let accounts = 0; async function seedOpportunity(): Promise { - const account = await ql.insert('crm_account', { name: 'Acme Industrial' }, { context: sysCtx }); + const account = await ql.insert('crm_account', { name: `Acme Industrial ${++accounts}` }, { context: sysCtx }); const row = await ql.insert( 'crm_opportunity', { diff --git a/test/campaign-member-cascade.test.ts b/test/campaign-member-cascade.test.ts index 024e08da..0a790a93 100644 --- a/test/campaign-member-cascade.test.ts +++ b/test/campaign-member-cascade.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; /** @@ -107,12 +107,10 @@ describe('crm_campaign_member declares cascade on both party lookups', () => { * metadata-only assertion can show that a delete now succeeds, and no hook * harness runs the referential pass at all. * - * `InMemoryDriver` is used for the same reason `object-validation-predicates` - * uses it: it stores only the columns a row was written with, so the merged - * record handed to the validator has ABSENT keys — the shape that made the rule - * fire in the first place. A column-complete driver would still reproduce this - * bug (the cleared column is `null`, not absent, and `isBlank(null)` is true), - * but the sparse one exercises the harder shape. + * On a real SQLite database, column-complete: the cleared column is `null`, + * and `isBlank(null)` is true, so this reproduces the bug as it was found on + * `driver-memory` (whose merged record had the key ABSENT). The absent-key + * shape itself is swept per predicate in `object-validation-predicates`. */ describe('deleting an enrolled person', () => { let ql: AnyRec; @@ -120,7 +118,7 @@ describe('deleting an enrolled person', () => { beforeEach(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_campaign_member: CAMPAIGN_MEMBER, crm_campaign: byName('crm_campaign'), @@ -129,6 +127,7 @@ describe('deleting an enrolled person', () => { crm_account: byName('crm_account'), } as never, })) as never; + await ql.syncSchemas(); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/case-assignment.test.ts b/test/case-assignment.test.ts index 4ce4e045..ff62ab0c 100644 --- a/test/case-assignment.test.ts +++ b/test/case-assignment.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { extractHookBody } from '@objectstack/cli/hook-body'; import stack, { localePacks } from './helpers/composed-stack'; @@ -137,7 +137,7 @@ describe('the transfer gate cannot see a beforeInsert owner_id stamp on crm_case beforeAll(async () => { seen = []; ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_case: { name: 'crm_case', @@ -150,6 +150,7 @@ describe('the transfer gate cannot see a beforeInsert owner_id stamp on crm_case }, } as never, })) as never; + await ql.syncSchemas(); // The SAME seam `@objectstack/plugin-security` registers the #3004 owner_id // write guard on. A RECORDER, not a gate: what is being measured is @@ -540,7 +541,7 @@ describe('the transfer gate on the UPDATE door: which escalation seam it can see beforeAll(async () => { seen = []; ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_case: { name: 'crm_case', @@ -553,6 +554,7 @@ describe('the transfer gate on the UPDATE door: which escalation seam it can see }, } as never, })) as never; + await ql.syncSchemas(); // Same recorder, same seam `@objectstack/plugin-security` registers the // #3004 guard on. Visibility is what is being measured, not the verdict. diff --git a/test/churn-health-score-block.test.ts b/test/churn-health-score-block.test.ts index b3f093ce..cde0847e 100644 --- a/test/churn-health-score-block.test.ts +++ b/test/churn-health-score-block.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import { resolveSeedRecord } from '@objectstack/formula'; import stack from './helpers/composed-stack'; @@ -161,9 +161,9 @@ const objectByName = new Map(objects.map((o) => [String(o.name), o])); const accountIsActiveDefault = (): boolean => (objectByName.get('crm_account')?.fields?.is_active as AnyRec | undefined)?.defaultValue === true; -const makeEngine = () => - ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, +const makeEngine = async () => { + const ql = await ObjectQL.create({ + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { // Only the columns the four churn blocks read. The object suite owns the // full schema; the contract here is "which rows does each block select". @@ -183,6 +183,9 @@ const makeEngine = () => }, } as never, }); + await ql.syncSchemas(); + return ql; +}; /** The synthetic row that isolates the claim from the seeds — header, point 3. */ const ACTIVE_AND_CHURNING = 'Zzz Actively-Worked Churning Account'; diff --git a/test/decorative-field-sweep.test.ts b/test/decorative-field-sweep.test.ts index 806c3cb1..aa58337c 100644 --- a/test/decorative-field-sweep.test.ts +++ b/test/decorative-field-sweep.test.ts @@ -214,7 +214,7 @@ describe('the roll-up actually computes, and keeps computing (#1182)', () => { beforeAll(async () => { process.env.OS_REGISTRY_LOG ??= 'silent'; kernel = new ObjectKernel({ logger: { level: 'silent' } } as never); - await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory', config: {} } as never)); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } } as never)); await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_sweep' } as never)); await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_sweep' } as never)); // The app's own metadata is the subject. Seed data is skipped — the three diff --git a/test/event-attendee-cascade.test.ts b/test/event-attendee-cascade.test.ts index 3a7ef87a..758826f3 100644 --- a/test/event-attendee-cascade.test.ts +++ b/test/event-attendee-cascade.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { SysUser } from '@objectstack/platform-objects'; import stack from './helpers/composed-stack'; @@ -171,10 +171,9 @@ describe('crm_event_attendee declares cascade on all three party lookups', () => * metadata-only assertion can show that a delete now succeeds, and no hook * harness runs the referential pass at all. * - * `InMemoryDriver` is used for the same reason `object-validation-predicates` - * uses it: it stores only the columns a row was written with, so the merged - * record handed to the validator has ABSENT keys — the shape that made the rule - * fire in the first place. + * On a real SQLite database, column-complete: the cleared column is `null`. + * The rule first fired on `driver-memory`, whose merged record had the key + * ABSENT; that shape is swept per predicate in `object-validation-predicates`. * * `sys_user` is the PLATFORM object, imported rather than stubbed: the whole * user-side decision rests on a real account erasure reaching this same delete @@ -189,7 +188,7 @@ describe('deleting a person who attended a meeting', () => { beforeEach(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_event_attendee: EVENT_ATTENDEE, crm_event: byName('crm_event'), @@ -199,6 +198,7 @@ describe('deleting a person who attended a meeting', () => { sys_user: SysUser, } as never, })) as never; + await ql.syncSchemas(); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/flow-condition-totality.test.ts b/test/flow-condition-totality.test.ts index 3a187617..09196fdd 100644 --- a/test/flow-condition-totality.test.ts +++ b/test/flow-condition-totality.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import { RecordChangeTrigger } from '@objectstack/trigger-record-change'; import stack from './helpers/composed-stack'; @@ -453,17 +453,32 @@ describe('flow conditions are TOTAL on the real engine', () => { }); /** - * End-to-end, on the driver that actually produces the sparse record. + * End-to-end, on a real engine over a real SQLite database. * * Everything above evaluates conditions in isolation. This boots a real - * ObjectQL over `InMemoryDriver`, a real `AutomationEngine`, and the real - * `RecordChangeTrigger` that wires them together, then performs the ordinary - * writes that reproduced each of the three defects. Before the guards landed + * ObjectQL, a real `AutomationEngine`, and the real `RecordChangeTrigger` that + * wires them together, then performs the ordinary writes that reproduced each + * of the three defects. They were reproduced on `driver-memory`, whose + * post-write row omitted every unwritten column; before the guards landed * every one of these logged * `Trigger-fired run of flow '…' failed: condition failed to evaluate as CEL` * and the automation silently did not happen. + * + * Since the maintainer's ruling on #2092 every test runs on SQL. Measured on + * 18.0.0-next.0 for #2092, with `case_escalation_on_create`'s start condition + * put back to its unguarded form: a born-critical case written through the + * trigger no longer hands the condition an absent key on EITHER driver (the + * run evaluates, no abort), while the same condition handed a record without + * `escalated_date` directly still aborts with `No such key`. That matches the + * 18.0.0-next.0 upgrade's own note (#2091): the trigger's `record` is + * materialised over every declared field. So these writes are not expected to + * reach the absent-key shape; what they pin is that each flow runs end to end + * without a condition abort on the driver a deployed install uses. The + * absent-key shape (a sparse datasource such as `driver-mongodb`) is held by + * the `answers on a record with no keys at all` and `any single referenced key + * is the one that is missing` sweeps above. */ -describe('conditions answer on a driver whose stored record omits the key', () => { +describe('conditions answer end-to-end on the real engine', () => { const objMap = Object.fromEntries(objects.map((o) => [o.name as string, o])); interface Booted { @@ -478,7 +493,7 @@ describe('conditions answer on a driver whose stored record omits the key', () = silent.child = () => silent; const ql: AnyRec = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: objMap as never, // `logger` is honoured at runtime but is not on the published options // type. Without it this suite prints every engine INFO line plus the @@ -486,6 +501,7 @@ describe('conditions answer on a driver whose stored record omits the key', () = // user-less harness, which reads as a broken build when it is not. ...({ logger: silent } as object), })) as never; + await ql.syncSchemas(); const engine = new AutomationEngine(silent); installBuiltinNodes(engine, { @@ -531,32 +547,6 @@ describe('conditions answer on a driver whose stored record omits the key', () = .filter((r) => /failed to evaluate as CEL|No such key|no such overload/.test(r.error ?? '')) .map((r) => `${r.flow}: ${String(r.error).split('\n')[0]}`); - it('stores no key for a column it was never given — the precondition', async () => { - const b = await boot([]); - try { - const api = b.ql.createContext({ isSystem: true }); - const row = await api.object('crm_case').insert({ - subject: 'Prod outage', description: 'Everything is down', - priority: 'critical', status: 'new', - }); - const stored = await api.object('crm_case').findOne({ where: { id: row.id } }); - // Not `toBeNull()`: the key is ABSENT, which is the whole point. If a - // platform upgrade makes this driver column-complete, this assertion - // fails and everything below stops proving anything — intended signal. - expect('escalated_date' in (stored ?? {})).toBe(false); - // …and `owner_id` has no schema-level filler at all — the acting user is - // stamped onto it by the security middleware (#548), which this harness - // does not install and which a system write short-circuits anyway. So the - // column is missing rather than null, which is precisely the shape - // `contact_welcome`'s `has(record.owner_id)` guard has to survive. The - // positive direction — a USER insert really does come back owned — is - // pinned in `test/ownership-model.test.ts`. - expect('owner_id' in (stored ?? {})).toBe(false); - } finally { - await b.close(); - } - }, 60_000); - it('case_escalation_on_create: a case BORN critical still evaluates', async () => { const b = await boot(['case_escalation_on_create']); try { diff --git a/test/flow-filter-today-token.test.ts b/test/flow-filter-today-token.test.ts index 345c9f83..97b34506 100644 --- a/test/flow-filter-today-token.test.ts +++ b/test/flow-filter-today-token.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import type * as Automation from '@objectstack/spec/automation'; import { allFlows } from '../objectstack.composition'; @@ -265,14 +265,8 @@ describe('{TODAY()} through a real ObjectQL + real AutomationEngine', () => { let data: AnyRec; const seedContracts = async () => { - // ⛔ No `tenantId` here. From @objectstack/driver-memory 17.4.0 the in-memory - // driver REFUSES any call the engine hands a tenant scope - // (`MemoryMultiTenantUnsupportedError`, objectstack#16589): it has no - // row-level tenant isolation, so answering would read across organizations. - // This fixture holds one implicit tenant and measures nothing about tenancy, - // so the honest shape is not to ask an unisolating driver to isolate. ⛔ Do - // NOT reach for `tenancy: { enabled: false }` instead — the refusal names that - // as the wrong answer for data that really is per-organization. + // No `tenantId` here: this fixture holds one implicit tenant and measures + // nothing about tenancy. const api: AnyRec = ql.createContext({ isSystem: true, userId: 'u1' } as never); await api.object('crm_contract').insert({ id: 'k_past', contract_number: 'C-1', status: 'activated', end_date: ymd(-3), owner_id: 'rep1', @@ -297,9 +291,10 @@ describe('{TODAY()} through a real ObjectQL + real AutomationEngine', () => { beforeAll(async () => { ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: OBJECTS, }); + await ql.syncSchemas(); seen = []; const anyQl = ql as AnyRec; // A thin recorder over the SAME object the runtime registers as `'data'`, @@ -443,14 +438,8 @@ describe('{TODAY()} through a real ObjectQL + real AutomationEngine', () => { // 2. The rows really moved — the query selected, and only the right ones. // `k_today` is the boundary row and `k_future` the control; either one // flipping means the resolved value was not today. - // ⛔ No `tenantId` here. From @objectstack/driver-memory 17.4.0 the in-memory - // driver REFUSES any call the engine hands a tenant scope - // (`MemoryMultiTenantUnsupportedError`, objectstack#16589): it has no - // row-level tenant isolation, so answering would read across organizations. - // This fixture holds one implicit tenant and measures nothing about tenancy, - // so the honest shape is not to ask an unisolating driver to isolate. ⛔ Do - // NOT reach for `tenancy: { enabled: false }` instead — the refusal names that - // as the wrong answer for data that really is per-organization. + // No `tenantId` here: this fixture holds one implicit tenant and measures + // nothing about tenancy. const api: AnyRec = ql.createContext({ isSystem: true, userId: 'u1' } as never); const rows: AnyRec[] = await api.object('crm_contract').find({ where: {} }); const byId = Object.fromEntries(rows.map((r) => [r.id, r.status])); diff --git a/test/flow-variable-conditions.test.ts b/test/flow-variable-conditions.test.ts index 8e819099..66b2603d 100644 --- a/test/flow-variable-conditions.test.ts +++ b/test/flow-variable-conditions.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { cel } from '@objectstack/spec'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import stack from './helpers/composed-stack'; import { flowGraphDeep, regionsOf } from './helpers/flow-regions'; @@ -82,8 +82,8 @@ import { flowGraphDeep, regionsOf } from './helpers/flow-regions'; * * **What binds a variable.** `get_record` runs * `if (outputVariable) variables.set(outputVariable, record)` unconditionally, - * and `findOne` answers a miss with `null` (measured against `InMemoryDriver` - * below) — so after a `get_record`, the variable IS bound, possibly to `null`. + * and `findOne` answers a miss with `null` (measured on SQL below) — so after + * a `get_record`, the variable IS bound, possibly to `null`. * `assignment` sets every key of `config.assignments`. A `loop` binds its * `iteratorVariable` inside the body region, which runs in the ENCLOSING * variable scope. A failed node throws and the run stops, so a node that could @@ -924,7 +924,7 @@ describe('declared defaults are seeded by the real engine, not just by this file }); // ═══════════════════════════════════════════════════════════════════════════ -// End-to-end, on the engine + driver that actually produce the shapes +// End-to-end, on the real engine over a real SQLite database // ═══════════════════════════════════════════════════════════════════════════ describe('the two defects, reproduced end-to-end', () => { @@ -941,12 +941,13 @@ describe('the two defects, reproduced end-to-end', () => { silent.child = () => silent; const ql: AnyRec = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: objMap as never, // `logger` is honoured at runtime but is not on the published options // type — without it this suite prints every engine INFO line. ...({ logger: silent } as object), })) as never; + await ql.syncSchemas(); const engine = new AutomationEngine(silent); installBuiltinNodes(engine, { @@ -986,15 +987,6 @@ describe('the two defects, reproduced end-to-end', () => { // Not `toBeFalsy()`: `null` specifically is what `get_record` then binds, // and `vars.X.f` on a null X aborts with `No such key: f`. expect(miss).toBeNull(); - // …while `crm_campaign.status` IS required, so the sparse-COLUMN variant - // was never the exposure here. If that changes this assertion fails and - // the guard's second clause starts earning its keep. - const camp = await api.object('crm_campaign').insert({ - name: 'Spring', type: 'email', status: 'planning', - start_date: '2026-01-01', end_date: '2026-03-01', - }); - const stored = await api.object('crm_campaign').findOne({ where: { id: camp.id } }); - expect('status' in (stored ?? {})).toBe(true); } finally { await b.close(); } diff --git a/test/forecast-current-quarter-view.test.ts b/test/forecast-current-quarter-view.test.ts index 6a32dba8..f84e1b39 100644 --- a/test/forecast-current-quarter-view.test.ts +++ b/test/forecast-current-quarter-view.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; /** @@ -512,7 +512,7 @@ describe('this_quarter_forecasts returns the current quarter, on the real engine beforeAll(async () => { ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_forecast: { name: 'crm_forecast', @@ -527,14 +527,9 @@ describe('this_quarter_forecasts returns the current quarter, on the real engine }, } as never, }); - // ⛔ No `tenantId` here. From @objectstack/driver-memory 17.4.0 the in-memory - // driver REFUSES any call the engine hands a tenant scope - // (`MemoryMultiTenantUnsupportedError`, objectstack#16589): it has no - // row-level tenant isolation, so answering would read across organizations. - // This fixture holds one implicit tenant and measures nothing about tenancy, - // so the honest shape is not to ask an unisolating driver to isolate. ⛔ Do - // NOT reach for `tenancy: { enabled: false }` instead — the refusal names that - // as the wrong answer for data that really is per-organization. + await ql.syncSchemas(); + // No `tenantId` here: this fixture holds one implicit tenant and measures + // nothing about tenancy. api = ql.createContext({ isSystem: true, userId: 'usr_1' } as never); // The current quarter, as the 03:00 sweep opens it — one row per owner. @@ -661,7 +656,7 @@ describe('closing_this_quarter returns this quarter only, on the real engine (#7 beforeAll(async () => { ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity: { name: 'crm_opportunity', @@ -678,14 +673,9 @@ describe('closing_this_quarter returns this quarter only, on the real engine (#7 }, } as never, }); - // ⛔ No `tenantId` here. From @objectstack/driver-memory 17.4.0 the in-memory - // driver REFUSES any call the engine hands a tenant scope - // (`MemoryMultiTenantUnsupportedError`, objectstack#16589): it has no - // row-level tenant isolation, so answering would read across organizations. - // This fixture holds one implicit tenant and measures nothing about tenancy, - // so the honest shape is not to ask an unisolating driver to isolate. ⛔ Do - // NOT reach for `tenancy: { enabled: false }` instead — the refusal names that - // as the wrong answer for data that really is per-organization. + await ql.syncSchemas(); + // No `tenantId` here: this fixture holds one implicit tenant and measures + // nothing about tenancy. api = ql.createContext({ isSystem: true, userId: 'usr_1' } as never); for (const f of fixtures) { await api.object('crm_opportunity').insert({ diff --git a/test/forecast-period-scope.test.ts b/test/forecast-period-scope.test.ts index 5a400e10..211e8390 100644 --- a/test/forecast-period-scope.test.ts +++ b/test/forecast-period-scope.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import stack from './helpers/composed-stack'; import { ForecastDataset } from '../src/sales/datasets/forecast.dataset'; @@ -180,9 +180,9 @@ const ANALYTICS_COLUMNS = [ 'closed_amount', 'pipeline_amount', 'commit_amount', ] as const; -const makeEngine = () => - ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, +const makeEngine = async () => { + const ql = await ObjectQL.create({ + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_forecast: { name: 'crm_forecast', @@ -200,6 +200,9 @@ const makeEngine = () => }, } as never, }); + await ql.syncSchemas(); + return ql; +}; /** * The current-quarter quota, as it reaches a live table. diff --git a/test/knowledge-article-share-links.test.ts b/test/knowledge-article-share-links.test.ts index 4064edf5..1a2c920f 100644 --- a/test/knowledge-article-share-links.test.ts +++ b/test/knowledge-article-share-links.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { SysUser } from '@objectstack/platform-objects'; import { ExpressionEngine } from '@objectstack/formula'; import { materializeDeclaredFields } from '@objectstack/objectql/core'; @@ -175,7 +175,7 @@ describe('the two-sided acceptance criterion, on the real ShareLinkService', () beforeEach(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_knowledge_article: ARTICLE, crm_case: byName('crm_case'), @@ -185,6 +185,7 @@ describe('the two-sided acceptance criterion, on the real ShareLinkService', () sys_share_link: SysShareLink, } as never, })) as never; + await ql.syncSchemas(); api = ql.createContext({ isSystem: true }); for (const [key, status, audience] of FIXTURES) { diff --git a/test/knowledge-deflection.test.ts b/test/knowledge-deflection.test.ts index 082ad578..9d99a889 100644 --- a/test/knowledge-deflection.test.ts +++ b/test/knowledge-deflection.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, applySystemFields } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import type { VerifyStack } from '@objectstack/verify'; @@ -227,147 +226,137 @@ const makeAnalytics = (ql: AnyRec) => }); /** - * The same suite, run twice: once on `InMemoryDriver` (whose stored rows OMIT - * unwritten columns) and once on real SQLite (column-complete rows, NULL where - * the app wrote nothing). Those two shapes disagree about what "absent" means, - * which is precisely the axis a non-NULL-counting measure gets wrong — so a - * single-driver green would not be evidence. + * The measures on real SQLite: column-complete rows, NULL where the app wrote + * nothing — the shape a deployed install stores, and the axis a + * non-NULL-counting measure gets wrong. (Through #2031 this suite also ran on + * `driver-memory`; the maintainer's ruling on #2092 moved every test onto SQL.) */ -for (const driverName of ['memory', 'sqlite'] as const) { - describe(`the deflection measures over ${driverName}`, () => { - let ql: AnyRec; - let analytics: AnalyticsService; - - beforeAll(async () => { - if (driverName === 'sqlite') { - const driver = new SqliteWasmDriver({ filename: ':memory:' }); - await driver.connect(); - // `ObjectQL.create` wires the datasource but emits no DDL — the exact - // call the runtime makes at boot. - for (const obj of [kase, article]) { - const materialized = applySystemFields(obj as never, { multiTenant: false }) as AnyRec; - await driver.initObjects([{ - name: obj.name, - fields: materialized.fields as Record, - indexes: materialized.indexes, - } as never]); - } - ql = (await ObjectQL.create({ - datasources: { default: driver }, - objects: { crm_case: kase, crm_knowledge_article: article } as never, - })) as never; - } else { - ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, - objects: { crm_case: kase, crm_knowledge_article: article } as never, - })) as never; - } - analytics = makeAnalytics(ql); - }, 60_000); - - afterAll(async () => { - await ql?.close(); - }); +describe('the deflection measures over sqlite', () => { + let ql: AnyRec; + let analytics: AnalyticsService; + + beforeAll(async () => { + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + // `ObjectQL.create` wires the datasource but emits no DDL — the exact + // call the runtime makes at boot. + for (const obj of [kase, article]) { + const materialized = applySystemFields(obj as never, { multiTenant: false }) as AnyRec; + await driver.initObjects([{ + name: obj.name, + fields: materialized.fields as Record, + indexes: materialized.indexes, + } as never]); + } + ql = (await ObjectQL.create({ + datasources: { default: driver }, + objects: { crm_case: kase, crm_knowledge_article: article } as never, + })) as never; + analytics = makeAnalytics(ql); + }, 60_000); + + afterAll(async () => { + await ql?.close(); + }); - const api = () => ql.createContext({ isSystem: true }); - - const addCase = async (rec: AnyRec) => { - const row: AnyRec = {}; - for (const c of CASE_COLUMNS) if (rec[c] !== undefined) row[c] = rec[c]; - return api().object('crm_case').insert(row); - }; - - /** Run a SHIPPED widget binding through the real dataset executor. */ - const run = async (w: AnyRec) => { - const res = await analytics.queryDataset( - CaseDataset as never, - { - ...(w.dimensions ? { dimensions: w.dimensions } : {}), - measures: w.values, - ...(w.filter ? { runtimeFilter: w.filter as never } : {}), - } as never, - { isSystem: true } as never, - ); - return (res.rows ?? []) as AnyRec[]; - }; - - const rate = async (): Promise => (await run( - { values: ['closed_count', 'kb_resolved_count', 'kb_deflection_rate'] }, - ))[0] ?? {}; - - it('measures the rate the dashboard shows, and its two halves', async () => { - // 2 of 4 closed cases resolved by an article; one OPEN case that also - // names one, to prove `is_closed` really scopes both halves. - await addCase(caseRow({ subject: 'c1', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); - await addCase(caseRow({ subject: 'c2', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); - await addCase(caseRow({ subject: 'c3', is_closed: true, status: 'closed' })); - await addCase(caseRow({ subject: 'c4', is_closed: true, status: 'closed' })); - await addCase(caseRow({ subject: 'c5', is_closed: false, resolved_by_article: 'ka2' })); - - const row = await rate(); - expect(row.closed_count).toBe(4); - expect(row.kb_resolved_count).toBe(2); - expect(row.kb_deflection_rate).toBeCloseTo(0.5, 5); - }); + const api = () => ql.createContext({ isSystem: true }); - it('rises when another closed case names an article', async () => { - const before = (await rate()).kb_deflection_rate as number; - await addCase(caseRow({ subject: 'c6', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); - const after = await rate(); - expect(after.kb_resolved_count).toBe(3); - expect(after.closed_count).toBe(5); - expect(after.kb_deflection_rate as number).toBeGreaterThan(before); - expect(after.kb_deflection_rate as number).toBeCloseTo(0.6, 5); - }); + const addCase = async (rec: AnyRec) => { + const row: AnyRec = {}; + for (const c of CASE_COLUMNS) if (rec[c] !== undefined) row[c] = rec[c]; + return api().object('crm_case').insert(row); + }; - it('falls when a closed case resolves without one', async () => { - const before = (await rate()).kb_deflection_rate as number; - await addCase(caseRow({ subject: 'c7', is_closed: true, status: 'closed' })); - const after = await rate(); - expect(after.kb_deflection_rate as number).toBeLessThan(before); - }); + /** Run a SHIPPED widget binding through the real dataset executor. */ + const run = async (w: AnyRec) => { + const res = await analytics.queryDataset( + CaseDataset as never, + { + ...(w.dimensions ? { dimensions: w.dimensions } : {}), + measures: w.values, + ...(w.filter ? { runtimeFilter: w.filter as never } : {}), + } as never, + { isSystem: true } as never, + ); + return (res.rows ?? []) as AnyRec[]; + }; - /** - * An OPEN case moves neither half. If it moved either, `is_closed` is not - * scoping the measure it is declared on — the missing-half shape of #614. - */ - it('does not move for an open case, whatever it names', async () => { - const before = await rate(); - await addCase(caseRow({ subject: 'c8', is_closed: false, resolved_by_article: 'ka1' })); - const after = await rate(); - expect(after.closed_count).toBe(before.closed_count); - expect(after.kb_resolved_count).toBe(before.kb_resolved_count); - }); + const rate = async (): Promise => (await run( + { values: ['closed_count', 'kb_resolved_count', 'kb_deflection_rate'] }, + ))[0] ?? {}; + + it('measures the rate the dashboard shows, and its two halves', async () => { + // 2 of 4 closed cases resolved by an article; one OPEN case that also + // names one, to prove `is_closed` really scopes both halves. + await addCase(caseRow({ subject: 'c1', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); + await addCase(caseRow({ subject: 'c2', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); + await addCase(caseRow({ subject: 'c3', is_closed: true, status: 'closed' })); + await addCase(caseRow({ subject: 'c4', is_closed: true, status: 'closed' })); + await addCase(caseRow({ subject: 'c5', is_closed: false, resolved_by_article: 'ka2' })); + + const row = await rate(); + expect(row.closed_count).toBe(4); + expect(row.kb_resolved_count).toBe(2); + expect(row.kb_deflection_rate).toBeCloseTo(0.5, 5); + }); - /** - * The empty string, at the layer that actually counts it. Everything above - * would still pass with the normalisation hook deleted — this is the case - * that would not. - */ - it('counts an EMPTY-STRING link, which is why the hook nulls it', async () => { - const before = await rate(); - await addCase(caseRow({ subject: 'c9', is_closed: true, status: 'closed', resolved_by_article: '' })); - const after = await rate(); - - expect(after.closed_count).toBe((before.closed_count as number) + 1); - // The numerator moving here is the DEFECT being demonstrated, not the - // behaviour being asked for — `case_resolution_article_normalize` is what - // guarantees such a row is never written in the first place. - expect(after.kb_resolved_count).toBe((before.kb_resolved_count as number) + 1); - }); + it('rises when another closed case names an article', async () => { + const before = (await rate()).kb_deflection_rate as number; + await addCase(caseRow({ subject: 'c6', is_closed: true, status: 'closed', resolved_by_article: 'ka1' })); + const after = await rate(); + expect(after.kb_resolved_count).toBe(3); + expect(after.closed_count).toBe(5); + expect(after.kb_deflection_rate as number).toBeGreaterThan(before); + expect(after.kb_deflection_rate as number).toBeCloseTo(0.6, 5); + }); - it('ranks articles by the closed cases they resolved', async () => { - const rows = await run({ - dimensions: ['resolved_article'], - values: ['kb_resolved_count'], - filter: { is_closed: true }, - }); - const ka1 = rows.find((r) => r.resolved_article === 'ka1'); - expect(ka1, `no ka1 bucket in ${JSON.stringify(rows)}`).toBeTruthy(); - expect(ka1!.kb_resolved_count).toBe(3); + it('falls when a closed case resolves without one', async () => { + const before = (await rate()).kb_deflection_rate as number; + await addCase(caseRow({ subject: 'c7', is_closed: true, status: 'closed' })); + const after = await rate(); + expect(after.kb_deflection_rate as number).toBeLessThan(before); + }); + + /** + * An OPEN case moves neither half. If it moved either, `is_closed` is not + * scoping the measure it is declared on — the missing-half shape of #614. + */ + it('does not move for an open case, whatever it names', async () => { + const before = await rate(); + await addCase(caseRow({ subject: 'c8', is_closed: false, resolved_by_article: 'ka1' })); + const after = await rate(); + expect(after.closed_count).toBe(before.closed_count); + expect(after.kb_resolved_count).toBe(before.kb_resolved_count); + }); + + /** + * The empty string, at the layer that actually counts it. Everything above + * would still pass with the normalisation hook deleted — this is the case + * that would not. + */ + it('counts an EMPTY-STRING link, which is why the hook nulls it', async () => { + const before = await rate(); + await addCase(caseRow({ subject: 'c9', is_closed: true, status: 'closed', resolved_by_article: '' })); + const after = await rate(); + + expect(after.closed_count).toBe((before.closed_count as number) + 1); + // The numerator moving here is the DEFECT being demonstrated, not the + // behaviour being asked for — `case_resolution_article_normalize` is what + // guarantees such a row is never written in the first place. + expect(after.kb_resolved_count).toBe((before.kb_resolved_count as number) + 1); + }); + + it('ranks articles by the closed cases they resolved', async () => { + const rows = await run({ + dimensions: ['resolved_article'], + values: ['kb_resolved_count'], + filter: { is_closed: true }, }); + const ka1 = rows.find((r) => r.resolved_article === 'ka1'); + expect(ka1, `no ka1 bucket in ${JSON.stringify(rows)}`).toBeTruthy(); + expect(ka1!.kb_resolved_count).toBe(3); }); -} +}); // ───────────────────────────────────── 4. the dashboard actually shows it ── diff --git a/test/lead-duplicate-management.test.ts b/test/lead-duplicate-management.test.ts index 7a3c2405..dc71d0b8 100644 --- a/test/lead-duplicate-management.test.ts +++ b/test/lead-duplicate-management.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { CrmTranslations } from '../src/sales/translations'; import leadHooks from '../src/sales/objects/lead.hook'; @@ -67,19 +67,23 @@ const LEAD_BASE = { // ─────────────────────────────────── the engine, with the real schema ── /** - * A real ObjectQL over the in-memory driver, carrying `crm_lead` + `crm_contact` - * exactly as the app declares them. + * A real ObjectQL over a real SQLite database, carrying `crm_lead` + + * `crm_contact` exactly as the app declares them. * - * The in-memory driver is not a convenience here, it is part of the test: it - * stores only the columns a row was written with, so a lead created without the - * duplicate fields comes back WITHOUT those keys. That is the record shape that - * used to make the validation abort (`No such key`) and be skipped. + * A lead created without the duplicate fields comes back with those columns + * NULL. The shape that used to make the validation abort (`No such key`) and be + * skipped — the keys ABSENT, as `driver-memory` stored them until the #2092 + * ruling moved every test onto SQL — is swept per predicate in + * `object-validation-predicates.test.ts`. */ -const makeEngine = () => - ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, +const makeEngine = async () => { + const ql = await ObjectQL.create({ + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_lead: lead, crm_contact: contact } as never, }); + await ql.syncSchemas(); + return ql; +}; describe('a repeated email is recorded, not rejected', () => { let ql: Awaited>; @@ -349,8 +353,9 @@ describe('the predicates are TOTAL — they return a verdict for every record sh * The unguarded first draft of these rules therefore let a lead be closed as * a duplicate with no survivor named — silently, and only on update. The * `has(...)` guards are what make the predicate answer instead of abort; - * the engine test above is the proof they work, and this is the reminder of - * why they cannot be "simplified" away. + * the no-keys sweep in `object-validation-predicates.test.ts` is the proof + * they work, and this is the reminder of why they cannot be "simplified" + * away. */ it('guards every field reference in the disqualification rule', () => { const source = celSource(rule?.condition); diff --git a/test/line-item-cascade.test.ts b/test/line-item-cascade.test.ts index 12259f88..da75a91e 100644 --- a/test/line-item-cascade.test.ts +++ b/test/line-item-cascade.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; /** @@ -128,7 +128,7 @@ describe('deleting an itemised parent', () => { beforeEach(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity_line_item: OPP_LINE_ITEM, crm_quote_line_item: QUOTE_LINE_ITEM, @@ -138,6 +138,7 @@ describe('deleting an itemised parent', () => { crm_product: byName('crm_product'), } as never, })) as never; + await ql.syncSchemas(); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/line-item-line-number.test.ts b/test/line-item-line-number.test.ts index 23966fe8..2afca4bf 100644 --- a/test/line-item-line-number.test.ts +++ b/test/line-item-line-number.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { extractHookBody } from '@objectstack/cli/hook-body'; import type { VerifyStack } from '@objectstack/verify'; @@ -71,7 +71,7 @@ const header: Record AnyRec> = { */ async function boot(withNumbering: boolean) { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity: byName('crm_opportunity'), crm_quote: byName('crm_quote'), @@ -79,6 +79,7 @@ async function boot(withNumbering: boolean) { crm_quote_line_item: byName('crm_quote_line_item'), } as never, })) as never; + await ql.syncSchemas(); for (const parent of PAIRS.map((p) => p.parentKey)) { for (const id of HEADERS) await ql.insert(parent, header[parent](id), { context: seed }); } diff --git a/test/object-validation-predicates.test.ts b/test/object-validation-predicates.test.ts index 6ecf8cfe..953b40ca 100644 --- a/test/object-validation-predicates.test.ts +++ b/test/object-validation-predicates.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { ObjectQL, evaluateValidationRules } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { REPO_ROOT } from './helpers/repo-root'; @@ -137,11 +137,13 @@ import { REPO_ROOT } from './helpers/repo-root'; * worse: a test that news up `InMemoryDriver` directly silently reverts to * the trap, and nothing checks for it. * 2. **It would have hidden this class of bug rather than fixed it.** The - * in-memory driver's sparse rows are the only place in the stack that - * exercises the "merged record lacks the key" shape. Making the double - * agree with SQL would make the suite green while every Mongo-backed - * install stayed silently unprotected. The sparse double is an asset — - * do not "fix" it. + * in-memory driver's sparse rows were then the only place in the stack + * that exercised the "merged record lacks the key" shape. Making the + * double agree with SQL would have made the suite green while every + * Mongo-backed install stayed silently unprotected. Since the + * maintainer's ruling on #2092 no test here runs on `driver-memory`; the + * shape is held instead by `predicates are TOTAL on the real engine` + * below, which hands every predicate a record with no keys at all. * 3. **It leaves the predicate depending on an environmental property.** A * rule whose correctness rests on "the storage layer happens to return * NULLs" is a rule one datasource swap away from being inert. Totality is @@ -649,8 +651,8 @@ describe('every stdlib call argument is null-guarded', () => { * The test above is a regex over source text and can be satisfied by a guard * that sits in the wrong place. This one hands each object's real predicates to * the engine's own `evaluateValidationRules` with `mode: 'update'` and a - * `previous` that has no keys at all — the exact shape the in-memory and Mongo - * drivers produce — and fails on any `failed to evaluate` warning, which is how + * `previous` that has no keys at all — the exact shape a sparse driver + * (`driver-mongodb`) produces — and fails on any `failed to evaluate` warning, which is how * the engine reports a predicate it could not evaluate (skipped through * 17.0.0-rc.1, write rejected from 17.0.0-rc.2 — same warning either way, which * is why this sweep still reads it). @@ -719,27 +721,28 @@ describe('predicates are TOTAL on the real engine', () => { }); /** - * End-to-end, on the driver that actually produces the sparse record. + * End-to-end, on a real engine over a real SQLite database. * * Everything above tests the predicate in isolation. This drives a real - * ObjectQL over `InMemoryDriver` — which stores only the columns a row was - * written with — and performs the exact operation from #630: insert a task - * without `completed_date`, then update its status to `completed`. Before the - * guards landed, the merged record had no `completed_date` key, CEL aborted, - * the engine logged `predicate failed to evaluate … — skipped`, and the save - * SUCCEEDED with the rule silently doing nothing. (On 17.0.0-rc.2 the same - * unguarded predicate would block that save instead — a different wrong answer - * from the same missing guard.) + * ObjectQL and performs the exact operation from #630: insert a task without + * `completed_date`, then update its status to `completed`. #630 was found on + * `driver-memory`, which stored only the columns a row was written with: the + * merged record had no `completed_date` key, CEL aborted, the engine logged + * `predicate failed to evaluate … — skipped`, and the save SUCCEEDED with the + * rule silently doing nothing. Since the #2092 ruling every test runs on SQL, + * whose row carries the column as NULL; the absent-key shape is swept per + * predicate above (`answers on a record with no keys at all`). */ -describe('the rule fires on a driver whose stored record omits the key', () => { +describe('the rule fires on the real engine', () => { const task = objects.find((o) => o.name === 'crm_task') as AnyRec; let ql: AnyRec; beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_task: task } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -753,15 +756,6 @@ describe('the rule fires on a driver whose stored record omits the key', () => { return { api, row }; }; - it('stores no key for a column it was never given — the precondition', async () => { - const { api, row } = await newTask(); - const stored = await api.object('crm_task').findOne({ where: { id: row.id } }); - // Not `toBeNull()`: the key is ABSENT, which is the whole point. If a - // platform upgrade makes this driver column-complete, this assertion fails - // and the tests below stop proving anything — that is the intended signal. - expect('completed_date' in (stored ?? {})).toBe(false); - }); - it('rejects status=completed with no completed_date', async () => { const { api, row } = await newTask(); await expect( diff --git a/test/opportunity-line-item-add-picker.test.ts b/test/opportunity-line-item-add-picker.test.ts index 7f6c7209..2675b325 100644 --- a/test/opportunity-line-item-add-picker.test.ts +++ b/test/opportunity-line-item-add-picker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack, { type AnyRec, walk } from './helpers/composed-stack'; import { products } from '../src/revenue/data/catalog.seed'; @@ -146,7 +146,7 @@ describe('the row a picker-shaped insert actually produces', () => { const boot = async (withHooks: boolean) => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity_line_item: LINE_ITEM, crm_opportunity: byName('crm_opportunity'), @@ -154,6 +154,7 @@ describe('the row a picker-shaped insert actually produces', () => { crm_product: PRODUCT, } as never, })) as never; + await ql.syncSchemas(); if (withHooks) { const hooks: AnyRec[] = (stack as never as AnyRec).hooks ?? []; bindHooksToEngine( diff --git a/test/ownership-model.test.ts b/test/ownership-model.test.ts index dd9aaa1b..4251d28e 100644 --- a/test/ownership-model.test.ts +++ b/test/ownership-model.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { VerifyStack } from '@objectstack/verify'; import stack from './helpers/composed-stack'; import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; @@ -190,7 +190,7 @@ describe('the transfer gate sees a hook’s ctx.api write, and not a beforeInser beforeAll(async () => { seen = []; ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_lead: { name: 'crm_lead', @@ -211,6 +211,7 @@ describe('the transfer gate sees a hook’s ctx.api write, and not a beforeInser }, } as never, })) as never; + await ql.syncSchemas(); // The SAME seam `@objectstack/plugin-security` registers its guard on. It // reads `opCtx.data` and passes everything through — a recorder, not a gate, diff --git a/test/quote-contact-required-when.test.ts b/test/quote-contact-required-when.test.ts index 8c7819ab..530d5628 100644 --- a/test/quote-contact-required-when.test.ts +++ b/test/quote-contact-required-when.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, applySystemFields } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { quotes } from '../src/revenue/data/revenue.seed'; @@ -34,10 +33,12 @@ import { quotes } from '../src/revenue/data/revenue.seed'; * `win-loss-capture.test.ts`), and `requiredWhen` has its own version of that * failure — a predicate that cannot evaluate is SKIPPED with a warning, so an * unguarded `record.status == "presented"` would read as enforced and require - * nothing. So the refusal is driven through a real ObjectQL on both driver - * shapes: `InMemoryDriver`, whose stored rows OMIT unwritten columns (the shape - * that makes an unguarded predicate abort), and a real SQLite database, whose - * rows are column-complete with NULLs. + * nothing. So the refusal is driven through a real ObjectQL over a real + * SQLite database, whose rows are column-complete with NULLs — the shape a + * deployed install stores. The absent-key shape (a sparse datasource such as + * `driver-mongodb`) is held per predicate by the totality sweep in + * `object-validation-predicates.test.ts`, which evaluates every rule against a + * record with no keys at all. * * Each refusal is asserted by its SUBSTANCE, not by "it threw": the envelope's * `code` (`VALIDATION_FAILED`, which `@objectstack/runtime`'s @@ -147,7 +148,7 @@ describe('the gate is declared where the platform can act on it', () => { }); }); -// ──────────────────────────── enforcement, on a driver with SPARSE records ── +// ──────────────────────────────────────── enforcement, on a real engine ── const DRAFT = { name: 'Gate Probe', @@ -158,14 +159,15 @@ const DRAFT = { total_price: 1234, }; -describe('the write is REFUSED, not warned about (in-memory driver)', () => { +describe('the write is REFUSED, not warned about', () => { let ql: AnyRec; beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_quote: quote } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -177,16 +179,6 @@ describe('the write is REFUSED, not warned about (in-memory driver)', () => { return { api, row }; }; - it('stores no key for a column it was never given — the precondition', async () => { - const { api, row } = await newDraft('Sparse Precondition'); - const stored = await api.object('crm_quote').findOne({ where: { id: row.id } }); - // Not `toBeNull()`: the key is ABSENT. That is the record shape that makes - // an unguarded predicate abort, and the reason this driver is the one the - // enforcement tests run on. - expect('crm_contact' in (stored ?? {})).toBe(false); - expect(stored?.status).toBe('draft'); - }); - it('refuses to PRESENT a quote with no contact, and the quote stays draft', async () => { const { api, row } = await newDraft('No Recipient'); const env = await refusal(() => @@ -295,9 +287,10 @@ describe('the write is REFUSED, not warned about (in-memory driver)', () => { // ────────────────────── the same contract on a real SQL database ────────── describe('the write is REFUSED on a real SQLite database too', () => { - // The in-memory driver hands back sparse records; a SQL driver hands back a - // full row with NULLs. Those are different inputs to the same predicate, and - // "which driver is underneath" is not something a marketplace app chooses. + // The column-complete precondition, pinned: a SQL driver hands back a full + // row with NULLs, and the predicate must refuse on that shape. (Its sparse + // twin ran on `driver-memory` until the #2092 ruling moved every test onto + // SQL; the no-keys shape is swept in `object-validation-predicates.test.ts`.) let ql: AnyRec; beforeAll(async () => { @@ -327,8 +320,7 @@ describe('the write is REFUSED on a real SQLite database too', () => { const row = await api.object('crm_quote').insert({ ...DRAFT, name: 'SQL Probe' }); const stored = await api.object('crm_quote').findOne({ where: { id: row.id } }); - // The opposite precondition to the in-memory suite: here the key IS - // present and null. Both shapes must reach the same verdict. + // The column-complete shape: the key IS present, and null. expect('crm_contact' in (stored ?? {})).toBe(true); expect(stored?.crm_contact ?? null).toBeNull(); @@ -382,11 +374,12 @@ describe('a row that was ALREADY presented when the rule landed', () => { /** A store holding one contact-less `presented` quote, then re-opened gated. */ const legacyRow = async () => { - const driver = new InMemoryDriver({ persistence: false }); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); const before: AnyRec = (await ObjectQL.create({ datasources: { default: driver }, objects: { crm_quote: ungated() } as never, })) as never; + await before.syncSchemas(); const row = await before .createContext({ isSystem: true }) .object('crm_quote') @@ -396,6 +389,7 @@ describe('a row that was ALREADY presented when the rule landed', () => { datasources: { default: driver }, objects: { crm_quote: quote } as never, })) as never; + await after.syncSchemas(); return { ql: after, api: after.createContext({ isSystem: true }), row }; }; diff --git a/test/quote-discount-ceiling.test.ts b/test/quote-discount-ceiling.test.ts index 8f4577ed..3601928e 100644 --- a/test/quote-discount-ceiling.test.ts +++ b/test/quote-discount-ceiling.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, applySystemFields } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { QUOTE_DISCOUNT_CEILING } from '../src/sales/objects/_thresholds'; @@ -45,9 +44,11 @@ import { quotes, quoteLineItems } from '../src/revenue/data/revenue.seed'; * with a table of five metadata surfaces that accept a rule and then do not * apply it, and this card found a sixth of its own: a `severity: 'warning'` * script validation is ADMITTED with no observable surface at all (measured - * below). So the refusal is driven through a real ObjectQL on both driver - * shapes: `InMemoryDriver`, whose stored rows omit unwritten columns, and a - * real SQLite database, whose rows are column-complete with NULLs. + * below). So the refusal is driven through a real ObjectQL over a real SQLite + * database, whose rows are column-complete with NULLs — the shape a deployed + * install stores. The absent-key shape (a sparse datasource such as + * `driver-mongodb`) is held per predicate by the totality sweep in + * `object-validation-predicates.test.ts`. * * Every refusal is asserted by its SUBSTANCE — the envelope `code`, the * per-field `code`, the message, and the fact that the record did not move. @@ -198,16 +199,17 @@ describe('the ceiling is declared where the platform can act on it', () => { }); }); -// ──────────────────────────────────────── the write is REFUSED (in-memory) ── +// ──────────────────────────────────────────────────── the write is REFUSED ── -describe('the write is REFUSED, not warned about (in-memory driver)', () => { +describe('the write is REFUSED, not warned about', () => { let ql: AnyRec; beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_quote: quote } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -252,17 +254,6 @@ describe('the write is REFUSED, not warned about (in-memory driver)', () => { expectCeilingRefusal(env); }); - it('leaves a quote with no discount at all alone', async () => { - // The sparse-row shape: `discount` is absent, not null. Without the - // `has(...)` guard this is where the predicate aborts and the rule dies. - const row = await api().object('crm_quote').insert({ ...DRAFT, name: 'No Discount' }); - const stored = await api().object('crm_quote').findOne({ where: { id: row.id } }); - expect('discount' in (stored ?? {})).toBe(false); - await api().object('crm_quote').update({ internal_notes: 'ok' }, { where: { id: row.id } }); - const after = await api().object('crm_quote').findOne({ where: { id: row.id } }); - expect(after?.internal_notes).toBe('ok'); - }); - it('leaves ordinary discounts alone across the whole lifecycle', async () => { // A rule that fires on legitimate work is a rule someone disables. const row = await api().object('crm_quote').insert({ ...DRAFT, name: 'Normal', discount: 20 }); @@ -290,9 +281,10 @@ describe('the write is REFUSED, not warned about (in-memory driver)', () => { // ─────────────────────── the same contract on a real SQL database ─────────── describe('the write is REFUSED on a real SQLite database too', () => { - // A SQL driver hands back a full row with NULLs where the in-memory driver - // hands back a sparse one. Those are different inputs to the same predicate, - // and which driver is underneath is not something a marketplace app chooses. + // The column-complete precondition, pinned: a SQL driver hands back a full + // row with NULLs, and the predicate must answer on that shape. (Its sparse + // twin ran on `driver-memory` until the #2092 ruling moved every test onto + // SQL; the no-keys shape is swept in `object-validation-predicates.test.ts`.) let ql: AnyRec; beforeAll(async () => { @@ -320,7 +312,7 @@ describe('the write is REFUSED on a real SQLite database too', () => { const row = await api.object('crm_quote').insert({ ...DRAFT, name: 'SQL Probe', discount: 5 }); const stored = await api.object('crm_quote').findOne({ where: { id: row.id } }); - // The opposite precondition to the in-memory suite: the column is present. + // The column-complete shape: the column is present. expect('discount' in (stored ?? {})).toBe(true); const env = await refusal(() => @@ -377,11 +369,12 @@ describe('the ceiling is an INVARIANT — it reaches a row that was already over /** A store holding one 90%-discount quote, then re-opened with the ceiling. */ const legacyRow = async (schema: AnyRec = quote) => { - const driver = new InMemoryDriver({ persistence: false }); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); const before: AnyRec = (await ObjectQL.create({ datasources: { default: driver }, objects: { crm_quote: ungated() } as never, })) as never; + await before.syncSchemas(); const row = await before .createContext({ isSystem: true }) .object('crm_quote') @@ -391,6 +384,7 @@ describe('the ceiling is an INVARIANT — it reaches a row that was already over datasources: { default: driver }, objects: { crm_quote: schema } as never, })) as never; + await after.syncSchemas(); return { ql: after, api: after.createContext({ isSystem: true }), row }; }; @@ -473,9 +467,10 @@ describe('a `warning` severity would be a rule that does nothing', () => { ); const ql: AnyRec = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_quote: warned } as never, })) as never; + await ql.syncSchemas(); const api = ql.createContext({ isSystem: true }); const row = await api.object('crm_quote').insert({ ...DRAFT, name: 'Warned', discount: 90 }); @@ -549,14 +544,15 @@ describe('the ceiling is declared on the line item too, as one policy', () => { }); }); -describe('the line write is REFUSED, not warned about (in-memory driver)', () => { +describe('the line write is REFUSED, not warned about', () => { let ql: AnyRec; beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_quote_line_item: lineItem } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -597,17 +593,14 @@ describe('the line write is REFUSED, not warned about (in-memory driver)', () => expectLineCeilingRefusal(env); }); - it('leaves a line with no discount written alone — and it is NOT the sparse shape', async () => { - // The opposite precondition to the quote object, measured rather than - // copied: `crm_quote_line_item.discount` carries `defaultValue: 0`, so even - // the in-memory driver stores the column on a write that never mentioned - // it. The `has(...)` guard is still load-bearing — the repo-wide sweep in + it('leaves a line with no discount written alone — the field default is 0', async () => { + // `crm_quote_line_item.discount` carries `defaultValue: 0`, so a write that + // never mentions it stores 0, not NULL. The `has(...)` guard is still + // load-bearing — the repo-wide sweep in // `object-validation-predicates.test.ts` evaluates every predicate against - // a record with NO keys at all — but this object cannot reach that state - // through an ordinary insert. + // a record with NO keys at all, the shape a sparse datasource hands back. const row = await api().object('crm_quote_line_item').insert({ ...LINE }); const stored = await api().object('crm_quote_line_item').findOne({ where: { id: row.id } }); - expect('discount' in (stored ?? {})).toBe(true); expect(stored?.discount).toBe(0); await api().object('crm_quote_line_item').update({ quantity: 3 }, { where: { id: row.id } }); @@ -657,11 +650,12 @@ describe('the line ceiling is an INVARIANT — it reaches a line already over it /** A store holding one 90%-discount line, then re-opened with the ceiling. */ const legacyLine = async (schema: AnyRec = lineItem) => { - const driver = new InMemoryDriver({ persistence: false }); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); const before: AnyRec = (await ObjectQL.create({ datasources: { default: driver }, objects: { crm_quote_line_item: ungatedLine() } as never, })) as never; + await before.syncSchemas(); const row = await before .createContext({ isSystem: true }) .object('crm_quote_line_item') @@ -671,6 +665,7 @@ describe('the line ceiling is an INVARIANT — it reaches a line already over it datasources: { default: driver }, objects: { crm_quote_line_item: schema } as never, })) as never; + await after.syncSchemas(); return { ql: after, api: after.createContext({ isSystem: true }), row }; }; @@ -728,9 +723,10 @@ describe('a quote with a 90% line discount cannot reach `presented`', () => { beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_quote: quote, crm_quote_line_item: lineItem } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -815,7 +811,7 @@ describe('a quote with a 90% line discount cannot reach `presented`', () => { // load-bearing input to #1109: an EFFECTIVE-discount ceiling has to read // both numbers at once, so it cannot be a script validation on either // object and would have to live where the rollup already joins them. - const driver = new InMemoryDriver({ persistence: false }); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); const ungated = JSON.parse(JSON.stringify(lineItem)) as AnyRec; ungated.validations = ((ungated.validations ?? []) as AnyRec[]).filter( (r) => r?.name !== CEILING_RULE, @@ -825,6 +821,7 @@ describe('a quote with a 90% line discount cannot reach `presented`', () => { datasources: { default: driver }, objects: { crm_quote: quote, crm_quote_line_item: ungated } as never, })) as never; + await before.syncSchemas(); const legacyApi = before.createContext({ isSystem: true }); const q = await legacyApi .object('crm_quote') @@ -837,6 +834,7 @@ describe('a quote with a 90% line discount cannot reach `presented`', () => { datasources: { default: driver }, objects: { crm_quote: quote, crm_quote_line_item: lineItem } as never, })) as never; + await after.syncSchemas(); const gatedApi = after.createContext({ isSystem: true }); // The LINE is frozen — this is the half the card fixes. diff --git a/test/readonly-write-semantics.test.ts b/test/readonly-write-semantics.test.ts index 79c17b6c..160a6c7c 100644 --- a/test/readonly-write-semantics.test.ts +++ b/test/readonly-write-semantics.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import type * as Automation from '@objectstack/spec/automation'; import { P, cel } from '@objectstack/spec'; @@ -65,7 +65,7 @@ type Flow = Automation.Flow; * The rule under test is the engine's own, so the AutomationEngine's * `data` service here IS a real `ObjectQL` — its flat `update(object, data, options)` * surface is exactly what the `update_record` executor calls — over a real - * `InMemoryDriver`. The probe object is purpose-built so the measurement never + * SQLite database. The probe object is purpose-built so the measurement never * depends on, or perturbs, the shipped `crm_case` declaration. * * First measured on `@objectstack/* 17.1.0`. RE-MEASURED 2026-09-03 against @@ -231,7 +231,7 @@ const readBack = async (id: string): Promise => beforeAll(async () => { ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { [PROBE]: probeObject, [ORDERED]: orderedObject } as never, hooks: [ { @@ -249,6 +249,7 @@ beforeAll(async () => { }, ], }); + await ql.syncSchemas(); }); afterAll(async () => { diff --git a/test/sharing-seeding.test.ts b/test/sharing-seeding.test.ts index 560cd93a..015e94d4 100644 --- a/test/sharing-seeding.test.ts +++ b/test/sharing-seeding.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { compileCelToFilter } from '@objectstack/formula'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from './helpers/composed-stack'; import { COUNTRY_TERRITORY, TERRITORY_OPTIONS, territoryFor } from '../src/sales/objects/_territory'; @@ -388,6 +388,9 @@ describe('what a sharing condition may contain (platform compiler, measured)', ( * * ### Measured on `@objectstack/driver-memory` 17.1.0 — the card's half is dead * + * (Since the maintainer's ruling on #2092 the witnesses below run on + * `driver-sqlite-wasm`, which answers every one of them the same way.) + * * `$not` no longer reaches mingo: the driver declares `$and`/`$or`/`$not` and * refuses anything else with a coded `INVALID_FILTER` instead of passing it * through. Measured directly, both the authored form and the portable @@ -407,10 +410,9 @@ function columnsFor(rule: AnyRec, names: string[]): AnyRec { } /** - * One record each rule must reach, and records it must not. - * - * Persistence is off: the memory driver otherwise writes - * `.objectstack/data/memory-driver.json` and would carry rows between runs. + * One record each rule must reach, and records it must not. Each rule gets a + * fresh in-memory SQLite database (`filename: ':memory:'`), so no row carries + * between runs. */ const RULE_WITNESSES: Record = { account_team_sharing: { @@ -499,11 +501,12 @@ describe('every seeded rule EXECUTES on the configured driver, not merely compil const witness = RULE_WITNESSES[name]; const columns = Object.keys(Object.assign({}, witness.reaches, ...witness.misses)); const ql = await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { [rule!.object as string]: { name: rule!.object, fields: columnsFor(rule!, columns) }, } as never, }); + await ql.syncSchemas(); try { const api = ql.createContext({ isSystem: true }); @@ -539,14 +542,16 @@ describe('every seeded rule EXECUTES on the configured driver, not merely compil * measured separately and a platform upgrade that breaks either one names * which. * - * The expected row sets are MEASURED against `driver-memory` 17.1.0, not - * reasoned about — including the two null-sensitive rows, which is the family - * objectstack#11065 records drivers disagreeing on (`avg` over a boolean - * answers `null` on memory and a number on sqlite). Note what was measured - * here: `Cyan` has a null `type`, and both `!= "customer"` and - * `!(… == "customer")` DO return it. That is this driver's answer, written - * down; if another driver answers differently, that difference belongs in a - * card, not in a `??` in this file. + * The expected row sets are MEASURED, not reasoned about — first against + * `driver-memory` 17.1.0, and re-measured on `driver-sqlite-wasm` + * 18.0.0-next.0 when the maintainer's ruling on #2092 moved every test onto + * SQL: every row answered identically on both. That includes the two + * null-sensitive rows, which is the family objectstack#11065 records drivers + * disagreeing on (`avg` over a boolean answers `null` on memory and a number + * on sqlite). Note what was measured here: `Cyan` has a null `type`, and both + * `!= "customer"` and `!(… == "customer")` DO return it. That is this driver's + * answer, written down; if another driver answers differently, that + * difference belongs in a card, not in a `??` in this file. */ describe('what the configured driver does with a compiled condition (measured)', () => { const FIXTURE = [ @@ -555,9 +560,9 @@ describe('what the configured driver does with a compiled condition (measured)', { name: 'Cyan', type: null, is_active: true, billing_country: 'JP', annual_revenue: 1000, territory: 'other' }, ]; - const makeAccounts = () => - ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + const makeAccounts = async () => { + const ql = await ObjectQL.create({ + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_account: { name: 'crm_account', @@ -569,6 +574,9 @@ describe('what the configured driver does with a compiled condition (measured)', }, } as never, }); + await ql.syncSchemas(); + return ql; + }; it.each([ ['equality + conjunction', 'record.type == "customer" && record.is_active == true', ['Acme']], @@ -607,13 +615,18 @@ describe('what the configured driver does with a compiled condition (measured)', // widening the result set. That is what makes "the rows came back" a // trustworthy signal above — and it is the platform behaviour that // replaced the mingo passthrough this card was filed against. + // + // Asserted by its envelope. The SQL driver withholds the offending key + // from the message (it names it in the server log only, because this + // predicate is not marked as author-written), so the `$nor` text the + // memory driver's message carried is not part of the contract here. const ql = await makeAccounts(); try { const api = ql.createContext({ isSystem: true }); for (const row of FIXTURE) await api.object('crm_account').insert(row as never); await expect( api.object('crm_account').find({ where: { $nor: [{ type: 'customer' }] } as never }), - ).rejects.toThrow(/\$nor/); + ).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400 }); } finally { await ql.close(); } diff --git a/test/sla-compliance-gauge.test.ts b/test/sla-compliance-gauge.test.ts index 838d64fa..949c88ab 100644 --- a/test/sla-compliance-gauge.test.ts +++ b/test/sla-compliance-gauge.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, applySystemFields } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import stack from './helpers/composed-stack'; @@ -26,7 +25,7 @@ import { CaseDataset } from '../src/service/datasets/case.dataset'; * * The numbers below are not read off the metadata. They come from running the * SHIPPED dataset measures and the SHIPPED widget binding through the REAL - * analytics executor, on BOTH drivers this app runs on, over controlled rows + * analytics executor, on a real SQLite database, over controlled rows * and then over the actual seeded case records — because the failure being * guarded produced no error at all, just a plausible wrong percentage. */ @@ -141,159 +140,150 @@ const makeAnalytics = (ql: AnyRec) => }); /** - * Both drivers, because they disagree about what "absent" means: the memory - * driver omits unwritten columns, SQLite stores NULL. A boolean the app never - * wrote is exactly the axis a filtered count gets wrong, and a single-driver - * green would not be evidence either way. + * On real SQLite, which stores NULL for a column the app never wrote. A boolean + * the app never wrote is exactly the axis a filtered count gets wrong. (Through + * #2031 this suite also ran on `driver-memory`; the maintainer's ruling on #2092 + * moved every test onto SQL.) */ -for (const driverName of ['memory', 'sqlite'] as const) { - describe(`the compliance measure over ${driverName}`, () => { - let ql: AnyRec; - let analytics: AnalyticsService; - - beforeAll(async () => { - if (driverName === 'sqlite') { - const driver = new SqliteWasmDriver({ filename: ':memory:' }); - await driver.connect(); - const materialized = applySystemFields(kase as never, { multiTenant: false }) as AnyRec; - await driver.initObjects([{ - name: kase.name, - fields: materialized.fields as Record, - indexes: materialized.indexes, - } as never]); - ql = (await ObjectQL.create({ - datasources: { default: driver }, - objects: { crm_case: kase } as never, - })) as never; - } else { - ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, - objects: { crm_case: kase } as never, - })) as never; - } - analytics = makeAnalytics(ql); - }, 60_000); - - afterAll(async () => { - await ql?.close(); - }); +describe('the compliance measure over sqlite', () => { + let ql: AnyRec; + let analytics: AnalyticsService; + + beforeAll(async () => { + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + const materialized = applySystemFields(kase as never, { multiTenant: false }) as AnyRec; + await driver.initObjects([{ + name: kase.name, + fields: materialized.fields as Record, + indexes: materialized.indexes, + } as never]); + ql = (await ObjectQL.create({ + datasources: { default: driver }, + objects: { crm_case: kase } as never, + })) as never; + analytics = makeAnalytics(ql); + }, 60_000); - const api = () => ql.createContext({ isSystem: true }); + afterAll(async () => { + await ql?.close(); + }); - const addCase = async (rec: AnyRec) => { - const row: AnyRec = {}; - for (const c of CASE_COLUMNS) if (rec[c] !== undefined) row[c] = rec[c]; - return api().object('crm_case').insert(row); - }; + const api = () => ql.createContext({ isSystem: true }); + + const addCase = async (rec: AnyRec) => { + const row: AnyRec = {}; + for (const c of CASE_COLUMNS) if (rec[c] !== undefined) row[c] = rec[c]; + return api().object('crm_case').insert(row); + }; + + /** Run a binding through the real dataset executor. */ + const run = async (w: AnyRec): Promise => { + const res = await analytics.queryDataset( + CaseDataset as never, + { + ...(w.dimensions ? { dimensions: w.dimensions } : {}), + measures: w.values, + ...(w.filter ? { runtimeFilter: w.filter as never } : {}), + } as never, + { isSystem: true } as never, + ); + return (res.rows ?? []) as AnyRec[]; + }; + + const compliance = async (): Promise => (await run({ + values: ['closed_count', 'sla_met_count', 'sla_compliance_rate'], + }))[0] ?? {}; - /** Run a binding through the real dataset executor. */ - const run = async (w: AnyRec): Promise => { - const res = await analytics.queryDataset( - CaseDataset as never, - { - ...(w.dimensions ? { dimensions: w.dimensions } : {}), - measures: w.values, - ...(w.filter ? { runtimeFilter: w.filter as never } : {}), - } as never, - { isSystem: true } as never, - ); - return (res.rows ?? []) as AnyRec[]; - }; - - const compliance = async (): Promise => (await run({ - values: ['closed_count', 'sla_met_count', 'sla_compliance_rate'], - }))[0] ?? {}; - - /** - * The breached share of CLOSED cases, from the two counts rather than from - * `avg_sla_violated`. MEASURED: an `avg` over a boolean column returns a - * number on SQLite (0.25 here) but `null` on the in-memory driver, so the - * dataset's own violation-rate measure is not a driver-stable yardstick to - * check compliance against. The counts are exact on both. - */ - const breachedShare = (row: AnyRec): number => - ((row.closed_count as number) - (row.sla_met_count as number)) / (row.closed_count as number); - - it('reads the complement of the violation rate, not the violation rate', async () => { - // 4 closed: 3 within SLA, 1 breached. Plus an OPEN breached case, to - // prove `is_closed` scopes both halves. - await addCase(caseRow({ subject: 'c1', is_closed: true, status: 'closed', is_sla_violated: false })); - await addCase(caseRow({ subject: 'c2', is_closed: true, status: 'closed', is_sla_violated: false })); - await addCase(caseRow({ subject: 'c3', is_closed: true, status: 'closed', is_sla_violated: false })); - await addCase(caseRow({ subject: 'c4', is_closed: true, status: 'closed', is_sla_violated: true })); - await addCase(caseRow({ subject: 'c5', is_closed: false, is_sla_violated: true })); - - const row = await compliance(); - expect(row.closed_count).toBe(4); - expect(row.sla_met_count).toBe(3); - expect(row.sla_compliance_rate as number).toBeCloseTo(0.75, 5); - - // The two quantities are complements, and the gauge shows the compliance - // one. This is the assertion the old binding could not have passed: it - // plotted the 0.25 under a title that says 达成率. - expect(breachedShare(row)).toBeCloseTo(0.25, 5); - expect((row.sla_compliance_rate as number) + breachedShare(row)).toBeCloseTo(1, 5); - }); + /** + * The breached share of CLOSED cases, from the two counts rather than from + * `avg_sla_violated`. MEASURED: an `avg` over a boolean column returns a + * number on SQLite (0.25 here) but `null` on the in-memory driver, so the + * dataset's own violation-rate measure is not a driver-stable yardstick to + * check compliance against. The counts are exact on both. + */ + const breachedShare = (row: AnyRec): number => + ((row.closed_count as number) - (row.sla_met_count as number)) / (row.closed_count as number); + + it('reads the complement of the violation rate, not the violation rate', async () => { + // 4 closed: 3 within SLA, 1 breached. Plus an OPEN breached case, to + // prove `is_closed` scopes both halves. + await addCase(caseRow({ subject: 'c1', is_closed: true, status: 'closed', is_sla_violated: false })); + await addCase(caseRow({ subject: 'c2', is_closed: true, status: 'closed', is_sla_violated: false })); + await addCase(caseRow({ subject: 'c3', is_closed: true, status: 'closed', is_sla_violated: false })); + await addCase(caseRow({ subject: 'c4', is_closed: true, status: 'closed', is_sla_violated: true })); + await addCase(caseRow({ subject: 'c5', is_closed: false, is_sla_violated: true })); + + const row = await compliance(); + expect(row.closed_count).toBe(4); + expect(row.sla_met_count).toBe(3); + expect(row.sla_compliance_rate as number).toBeCloseTo(0.75, 5); + + // The two quantities are complements, and the gauge shows the compliance + // one. This is the assertion the old binding could not have passed: it + // plotted the 0.25 under a title that says 达成率. + expect(breachedShare(row)).toBeCloseTo(0.25, 5); + expect((row.sla_compliance_rate as number) + breachedShare(row)).toBeCloseTo(1, 5); + }); - it('rises when a case closes within SLA', async () => { - const before = (await compliance()).sla_compliance_rate as number; - await addCase(caseRow({ subject: 'c6', is_closed: true, status: 'closed', is_sla_violated: false })); - const after = await compliance(); - expect(after.sla_compliance_rate as number).toBeGreaterThan(before); - expect(after.sla_compliance_rate as number).toBeCloseTo(0.8, 5); - }); + it('rises when a case closes within SLA', async () => { + const before = (await compliance()).sla_compliance_rate as number; + await addCase(caseRow({ subject: 'c6', is_closed: true, status: 'closed', is_sla_violated: false })); + const after = await compliance(); + expect(after.sla_compliance_rate as number).toBeGreaterThan(before); + expect(after.sla_compliance_rate as number).toBeCloseTo(0.8, 5); + }); - it('falls when a case closes having breached SLA', async () => { - const before = (await compliance()).sla_compliance_rate as number; - await addCase(caseRow({ subject: 'c7', is_closed: true, status: 'closed', is_sla_violated: true })); - expect((await compliance()).sla_compliance_rate as number).toBeLessThan(before); - }); + it('falls when a case closes having breached SLA', async () => { + const before = (await compliance()).sla_compliance_rate as number; + await addCase(caseRow({ subject: 'c7', is_closed: true, status: 'closed', is_sla_violated: true })); + expect((await compliance()).sla_compliance_rate as number).toBeLessThan(before); + }); - it('does not move for an open case, breached or not', async () => { - const before = await compliance(); - await addCase(caseRow({ subject: 'c8', is_closed: false, is_sla_violated: true })); - await addCase(caseRow({ subject: 'c9', is_closed: false, is_sla_violated: false })); - const after = await compliance(); - expect(after.closed_count).toBe(before.closed_count); - expect(after.sla_met_count).toBe(before.sla_met_count); - }); + it('does not move for an open case, breached or not', async () => { + const before = await compliance(); + await addCase(caseRow({ subject: 'c8', is_closed: false, is_sla_violated: true })); + await addCase(caseRow({ subject: 'c9', is_closed: false, is_sla_violated: false })); + const after = await compliance(); + expect(after.closed_count).toBe(before.closed_count); + expect(after.sla_met_count).toBe(before.sla_met_count); + }); - /** - * The NULL hole this spelling could have had, closed by measurement rather - * than by argument: `is_sla_violated` declares `defaultValue: false`, so a - * closed case inserted without the column still lands in the numerator on - * both drivers. If it did not, `sla_met_count + breached` would stop - * summing to `closed_count` and compliance would silently under-report. - */ - it('counts a closed case whose SLA flag was never written', async () => { - const before = await compliance(); - await addCase(caseRow({ subject: 'c10', is_closed: true, status: 'closed' })); - const after = await compliance(); - expect(after.closed_count).toBe((before.closed_count as number) + 1); - expect(after.sla_met_count).toBe((before.sla_met_count as number) + 1); - }); + /** + * The NULL hole this spelling could have had, closed by measurement rather + * than by argument: `is_sla_violated` declares `defaultValue: false`, so a + * closed case inserted without the column still lands in the numerator on + * both drivers. If it did not, `sla_met_count + breached` would stop + * summing to `closed_count` and compliance would silently under-report. + */ + it('counts a closed case whose SLA flag was never written', async () => { + const before = await compliance(); + await addCase(caseRow({ subject: 'c10', is_closed: true, status: 'closed' })); + const after = await compliance(); + expect(after.closed_count).toBe((before.closed_count as number) + 1); + expect(after.sla_met_count).toBe((before.sla_met_count as number) + 1); + }); - /** - * Numerator and denominator stay in step — the #614 failure mode is a half - * that scopes differently from the other half, and it returns a plausible - * number rather than an error. - */ - /** - * The ratio is met ÷ closed, in that order, and the executor really - * evaluates it rather than passing the numerator through. A swapped ratio - * raises nothing — it returns a number above 1, which a `0%` format prints - * as a plausible-looking 133%. - */ - it('computes met over closed, in that order', async () => { - const row = await compliance(); - expect(row.sla_compliance_rate as number).toBeCloseTo( - (row.sla_met_count as number) / (row.closed_count as number), 10, - ); - expect(row.sla_compliance_rate as number).toBeLessThanOrEqual(1); - expect(row.sla_compliance_rate as number).not.toBe(row.sla_met_count as number); - }); + /** + * Numerator and denominator stay in step — the #614 failure mode is a half + * that scopes differently from the other half, and it returns a plausible + * number rather than an error. + */ + /** + * The ratio is met ÷ closed, in that order, and the executor really + * evaluates it rather than passing the numerator through. A swapped ratio + * raises nothing — it returns a number above 1, which a `0%` format prints + * as a plausible-looking 133%. + */ + it('computes met over closed, in that order', async () => { + const row = await compliance(); + expect(row.sla_compliance_rate as number).toBeCloseTo( + (row.sla_met_count as number) / (row.closed_count as number), 10, + ); + expect(row.sla_compliance_rate as number).toBeLessThanOrEqual(1); + expect(row.sla_compliance_rate as number).not.toBe(row.sla_met_count as number); }); -} +}); // ───────────────────────── 3. the seeded org, end to end through the widget ── @@ -325,9 +315,10 @@ describe('the gauge reads met ÷ closed on the seeded demo org', () => { it('renders 25% through the shipped widget binding', async () => { const ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_case: kase } as never, })) as AnyRec; + await ql.syncSchemas(); try { const api = () => (ql as AnyRec).createContext({ isSystem: true }); for (const rec of seededCases) { diff --git a/test/undeclared-key-probe.test.ts b/test/undeclared-key-probe.test.ts index ffce4a0c..0689b9d9 100644 --- a/test/undeclared-key-probe.test.ts +++ b/test/undeclared-key-probe.test.ts @@ -35,7 +35,7 @@ import { Quote } from '../src/revenue/objects/quote.object'; * ## The caller path is CLOSED, and that is what half 1 pins * * A key the caller supplies is refused before anything happens — - * `INVALID_FIELD` / 400, identically on all three drivers. Upstream + * `INVALID_FIELD` / 400, identically on every driver measured. Upstream * objectstack#8682 (insert) and objectstack#8738 (update) put that door in, * ahead of hooks and ahead of statement construction. * @@ -52,7 +52,7 @@ import { Quote } from '../src/revenue/objects/quote.object'; * * The door sits in front of the hooks, so a key a hook itself assigns is never * checked against the schema. Whatever the hook wrote then reaches the driver - * — and the three drivers do three different things with it: + * — and on 17.1.0 the three drivers measured did three different things with it: * * - `memory` — accepted, stored, and returned on read. #1200's original * defect, alive on 17.1.0 on this path. @@ -231,13 +231,12 @@ async function measure(driver: string, config: AnyRec): Promise<{ kernels: AnyRe } /** - * `memory` is the sparse, schemaless store; `sqlite` is `@objectstack/driver-sql` - * on knex/better-sqlite3; `sqlite-wasm` is the WASM build of the same family. - * All three ship in this repo's dependencies, so all three are deployments a - * customer can actually run. + * `sqlite` is `@objectstack/driver-sql` on knex/better-sqlite3; `sqlite-wasm` is + * the WASM build of the same family. Both are deployments a customer can + * actually run. (`memory` was a third leg until the maintainer's ruling on + * #2092 moved every test onto SQL.) */ const DRIVERS: Array<{ driver: string; config: AnyRec }> = [ - { driver: 'memory', config: {} }, { driver: 'sqlite', config: { filename: ':memory:' } }, { driver: 'sqlite-wasm', config: { filename: ':memory:' } }, ]; @@ -313,25 +312,17 @@ describe.each(DRIVERS.map((d) => d.driver))( * refused it, but as a raw driver error carrying neither the ADR-0112 `code` nor * a `status`, so no caller could handle it by shape. * - * On 17.3.0 all three agree, and they agree on the strict answer: the write is + * On 17.3.0 all three agreed, and they agreed on the strict answer: the write is * refused with the same `INVALID_FIELD` / 400 envelope the declared-surface door * already produced (asserted in half 1 above). The hook door and the caller door * are now one contract. * * ⚠️ Read this as a REAL behaviour change for any app whose hook quietly wrote a * key its object does not declare: on `driver-memory` that write used to land - * and now refuses. This app writes no such key outside this probe. + * and now refuses. This app writes no such key outside this probe. (The memory + * leg itself left this file with the #2092 ruling; the SQL legs remain.) */ describe('half 2 — a key a HOOK writes is refused by every driver, in one envelope (#1200, closed)', () => { - it('memory no longer accepts it — the permissive driver came into line', () => { - const R = readings.get('memory')!; - expect(R.viaHook.ok, 'driver-memory accepted an undeclared key again').toBe(false); - expect(R.viaHookRow).toBeUndefined(); - // The object still declares no such field — the premise is unchanged; what - // changed is that the platform now refuses instead of storing it. - expect(Object.keys((OpportunityLineItem as AnyRec).fields)).not.toContain(KEY); - }); - it.each(['sqlite', 'sqlite-wasm'])('%s refuses it as an ADR-0112 envelope now', (driver) => { const R = readings.get(driver)!; expect(R.viaHook.ok).toBe(false); @@ -342,7 +333,7 @@ describe('half 2 — a key a HOOK writes is refused by every driver, in one enve expect(R.viaHookRow).toBeUndefined(); }); - it('so the three drivers DO agree — the convergence is the finding', () => { + it('so the SQL drivers DO agree — the convergence is the finding', () => { const outcomes = DRIVERS.map((d) => readings.get(d.driver)!.viaHook.ok); expect(new Set(outcomes).size).toBe(1); expect(outcomes.every((ok) => ok === false)).toBe(true); diff --git a/test/win-loss-capture.test.ts b/test/win-loss-capture.test.ts index 7cf05cdd..69b3deca 100644 --- a/test/win-loss-capture.test.ts +++ b/test/win-loss-capture.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, applySystemFields } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import stack from './helpers/composed-stack'; @@ -30,12 +29,13 @@ import { OpportunityDataset } from '../src/sales/datasets/opportunity.dataset'; * * So the bar for this issue was never "the rule is declared". It is: drive a * REAL write through a REAL driver and watch it be REJECTED. That is what the - * first two describe-blocks below do — on `InMemoryDriver` (whose stored rows - * omit unwritten columns, the shape that makes a non-total predicate abort) - * and again on a real SQLite database (column-complete rows, the shape a - * deployed install has). A rejection is asserted together with "and the record - * did not move", because a rule that reports a problem while the write lands - * anyway is the flow-condition failure mode above, not enforcement. + * first two describe-blocks below do, on a real SQLite database + * (column-complete rows, the shape a deployed install has). The absent-key + * shape a sparse datasource hands back is held per predicate by the totality + * sweep in `object-validation-predicates.test.ts`. A rejection is asserted + * together with "and the record did not move", because a rule that reports a + * problem while the write lands anyway is the flow-condition failure mode + * above, not enforcement. * * `crm_case.resolution_required_for_closed` — the pattern the issue points at * — was re-measured the same way rather than assumed, and it does block today; @@ -113,7 +113,7 @@ describe('the reason fields declare a conditional write contract', () => { }); }); -// ─────────────────────────── enforcement, on a driver with SPARSE records ── +// ──────────────────────────────────────── enforcement, on a real engine ── const OPEN_DEAL = { name: 'Enforcement Probe', @@ -123,14 +123,15 @@ const OPEN_DEAL = { close_date: '2099-01-01', }; -describe('the write is REJECTED, not warned about (in-memory driver)', () => { +describe('the write is REJECTED, not warned about', () => { let ql: AnyRec; beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity: opportunity } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -142,17 +143,6 @@ describe('the write is REJECTED, not warned about (in-memory driver)', () => { return { api, row }; }; - it('stores no key for a column it was never given — the precondition', async () => { - const { api, row } = await newDeal(); - const stored = await api.object('crm_opportunity').findOne({ where: { id: row.id } }); - // Not `toBeNull()`: the key is ABSENT. That is the record shape that makes - // an unguarded predicate abort, and the reason this driver is the one the - // enforcement tests run on. If a platform upgrade makes it - // column-complete, this fails — which is the intended signal, because the - // tests below would stop proving the hard case. - expect('loss_reason' in (stored ?? {})).toBe(false); - }); - it('refuses to close a deal as LOST with no loss_reason', async () => { const { api, row } = await newDeal(); await expect( @@ -232,10 +222,10 @@ describe('the write is REJECTED, not warned about (in-memory driver)', () => { // ────────────────────── the same contract on a real SQL database ────────── describe('the write is REJECTED on a real SQLite database too', () => { - // The in-memory driver hands back sparse records; a SQL driver hands back a - // full row with NULLs. Those are different inputs to the same predicate, and - // "which driver is underneath" is not something a marketplace app chooses — - // so the contract is measured on both rather than generalised from one. + // The column-complete precondition, pinned: a SQL driver hands back a full + // row with NULLs, and the rule must refuse on that shape. (Its sparse twin + // ran on `driver-memory` until the #2092 ruling moved every test onto SQL; + // the no-keys shape is swept in `object-validation-predicates.test.ts`.) let ql: AnyRec; beforeAll(async () => { @@ -266,8 +256,7 @@ describe('the write is REJECTED on a real SQLite database too', () => { const row = await api.object('crm_opportunity').insert({ ...OPEN_DEAL, name: 'SQL Probe' }); const stored = await api.object('crm_opportunity').findOne({ where: { id: row.id } }); - // The opposite precondition to the in-memory suite: here the key IS - // present and null. Both shapes must reach the same verdict. + // The column-complete shape: the key IS present, and null. expect('loss_reason' in (stored ?? {})).toBe(true); expect(stored?.loss_reason ?? null).toBeNull(); @@ -302,9 +291,10 @@ describe('crm_case.resolution_required_for_closed is still live', () => { beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_case: kase } as never, })) as never; + await ql.syncSchemas(); }); afterAll(async () => { await ql?.close(); @@ -382,9 +372,10 @@ describe('every settled seed carries its reason', () => { // is an expression the seeder resolves at write time, and this assertion is // about the validation verdict, not about date arithmetic. const ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity: opportunity } as never, })) as AnyRec; + await ql.syncSchemas(); try { const api = ql.createContext({ isSystem: true }); const isCel = (v: unknown) => !!v && typeof v === 'object' && 'source' in (v as AnyRec); @@ -434,7 +425,7 @@ describe('win rate is measured, and both halves are load-bearing', () => { beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { // A shape-only stand-in: this block tests which rows the measures // select and what they divide, not the object's own rules (those are @@ -455,6 +446,7 @@ describe('win rate is measured, and both halves are load-bearing', () => { }, } as never, })) as never; + await ql.syncSchemas(); analytics = new AnalyticsService({ // The same bridge `AnalyticsServicePlugin` wires at boot. @@ -724,7 +716,7 @@ describe('the shipped seeds produce a real win rate and a real loss breakdown', beforeAll(async () => { ql = (await ObjectQL.create({ - datasources: { default: new InMemoryDriver({ persistence: false }) }, + datasources: { default: new SqliteWasmDriver({ filename: ':memory:' }) }, objects: { crm_opportunity: { name: 'crm_opportunity', @@ -742,6 +734,7 @@ describe('the shipped seeds produce a real win rate and a real loss breakdown', }, } as never, })) as never; + await ql.syncSchemas(); const api = ql.createContext({ isSystem: true }); for (const rec of seedRecords) {