diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 8aaabc33fa..60c2e55be3 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -38,7 +38,6 @@ "jose": "^6.2.5" }, "devDependencies": { - "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/objectql": "workspace:*", "@types/node": "^26.1.2", diff --git a/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts index 7acd2d956c..a05f26890b 100644 --- a/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts +++ b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts @@ -7,7 +7,7 @@ * `convertWhere()` used to emit `{ field: { $regex: condition.value } }`, which * puts an unescaped, caller-supplied comparand (`/admin/list-users`' * `searchValue`, a SCIM filter value) into a PATTERN position. What that value - * then means depended on the backend under the auth path: + * then meant depended on the backend under the auth path: * * - driver-memory compiled it to `new RegExp(value)` — `a.b` matched `axb`, * `^x` anchored, and an unbalanced `(` was an illegal pattern; @@ -20,29 +20,93 @@ * member of the spec's `FILTER_OPERATORS`, i.e. one every backend is required * to evaluate, as a literal substring. * - * Two faces, deliberately: the first pins WHAT the adapter emits (the contract), - * the second pins what a real backend then ANSWERS (the behaviour). The first - * alone cannot see a translation that is spelled right and evaluated wrong; the - * second alone cannot say which operator earned the result. + * Three faces, deliberately: the first pins WHAT the adapter emits (the + * contract), the second pins what a real backend then ANSWERS (the behaviour), + * and the third pins the property that lets the second face FAIL at all (the + * discrimination). The first alone cannot see a translation that is spelled + * right and evaluated wrong; the second alone cannot say which operator earned + * the result; and without the third, face 2 would be an always-green pin the + * day the backend starts aliasing `$regex` again. + * + * ## Backend note (#5893 / #5830 / #5704) + * + * Face 2's backend was `InMemoryDriver` when this file landed with #5812; it is + * now `@objectstack/driver-sql` + better-sqlite3 `:memory:`, the harness its + * sibling `auth-where-operator-coverage.test.ts` already uses (PR #5880), built + * the way the rest of the repo builds an ephemeral store (`examples/app-crm`, + * `cli db clean`, PR #5715's `makeDefaultDriver()`). + * + * The swap was DEFERRED once, on measurement, and this is the deferral being + * discharged rather than forgotten — the history matters because it names the + * exact hazard this file now guards against: + * + * - **Then (#5830, PR #5880).** driver-sql routed `$regex` through the same + * `applyContainsLike` as `$contains` (a `case '$regex':` fallthrough), so + * the SQL backend answered the two operators cell-for-cell alike. Measured + * there: with the defect restored, memory failed 3 of these behavioural + * pins and sqlite passed all 4. Migrating then would have produced pins + * that are green because nothing distinguishes them — coverage that is + * blind to the very defect it names. + * - **Now (#5702, PR #6549).** The fallthrough is deleted and `$regex` is + * RETIRED: driver-sql refuses it by name in the ADR-0112 envelope + * (`code: 'INVALID_FILTER'`, `status: 400`), prescribing `$icontains`. The + * defect is therefore witnessed on the SQL arm again — not as a different + * row set, but as a REFUSAL, which is a strictly better reason: a bare + * `$regex` from this adapter no longer answers a subtly wrong question, it + * answers nothing and says why. + * + * Re-measured for #5893 before this file moved (both directions, `flock`-ed + * local run, and again with the defect restored): + * + * - fixed adapter, sqlite: `$contains 'a.b'` → `['a.b']`, `'^a'` → `[]`, + * `'('` → `['x(y']`, `'xb'` → `['axb']` — identical, value for value, to + * what memory answered, so the migration costs the fixture nothing; + * - defect restored (adapter emits a bare `$regex` again), sqlite: all four + * behavioural pins RED via the refusal, plus the contract face. + * + * Face 3 exists so that this stays true without anyone re-measuring by hand. It + * is the load-bearing half of the migration: if driver-sql ever re-aliases + * `$regex` onto the substring path, face 3 goes red and says so, instead of + * face 2 quietly reverting to the always-green pin #5830 refused to create. + * + * Case semantics are deliberately untested here, exactly as in the sibling + * file: sqlite's `LIKE` is ASCII case-INsensitive by default (so `$contains` + * folds on this backend while the contract layer calls it case-SENSITIVE per + * #5701 Q2=A — driver-sql pins that gap itself, in + * `sql-driver-icontains-and-retired-operators.test.ts`, which asserts + * `$contains` compiles WITHOUT `LOWER()` while `$icontains` compiles with it). + * Every fixture below is lower-case and no assertion depends on which way that + * per-driver alignment lands. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SqlDriver } from '@objectstack/driver-sql'; import { FILTER_OPERATORS } from '@objectstack/spec/data'; import type { QueryAST } from '@objectstack/spec/data'; import type { IDataEngine } from '@objectstack/core'; import { createObjectQLAdapterFactory } from './objectql-adapter'; -/** Keeps the driver's own lifecycle logging out of the test output. */ -const silentLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, -} as any; +/** + * The columns face 2 reads, declared — a real table has to be told. + * + * `emailVerified` / `createdAt` / `updatedAt` used to be seeded alongside these + * and are gone with the backend swap: they were camelCase keys no assertion + * ever read, which only a schemaless store would have accepted (`sys_user` + * spells them `email_verified` / `created_at` / `updated_at`). Declaring the + * fixture down to what it actually asserts on is #5806's "resolve by declaring, + * not by relaxing" — the alternative would be declaring three columns to hold + * values nothing looks at. + */ +const SYS_USER = { + name: 'sys_user', + fields: { + name: { type: 'text', name: 'name' }, + email: { type: 'text', name: 'email' }, + }, +}; /** - * A read-only engine facade over a REAL `InMemoryDriver`. + * A read-only engine facade over a REAL `SqlDriver`. * * Only the three read verbs the `contains` path uses are declared. The write * verbs are deliberately absent rather than stubbed: seeding goes through the @@ -50,7 +114,7 @@ const silentLogger = { * dispatch contract this test neither needs nor is able to honour * (`check:engine-double-contract`, #4550). */ -function memoryReadEngine(driver: InMemoryDriver): IDataEngine { +function sqlReadEngine(driver: SqlDriver): IDataEngine { // The query bag is forwarded with its declared driver-side type and no `any` // erasure: `query-options/no-any-erasure` (#4674/#4918) counts a test-side // `find(obj, … as any)` too, and nothing here needs to be off-contract. @@ -61,8 +125,6 @@ function memoryReadEngine(driver: InMemoryDriver): IDataEngine { } as unknown as IDataEngine; } -const NOW = new Date('2026-08-06T00:00:00.000Z').toISOString(); - /** Rows whose `name`s differ only in how a regex would read the comparand. */ const SEED = [ { id: 'u_literal', name: 'a.b', email: 'literal@example.com' }, @@ -70,14 +132,34 @@ const SEED = [ { id: 'u_paren', name: 'x(y', email: 'paren@example.com' }, ]; -async function seededAdapter() { - const driver = new InMemoryDriver({ logger: silentLogger }); - await driver.connect(); - for (const row of SEED) { - await driver.create('sys_user', { ...row, emailVerified: false, createdAt: NOW, updatedAt: NOW }); +/** + * Live `:memory:` databases, closed after each test — the database dies with + * its connection, so nothing touches the host filesystem, but a file this size + * would otherwise hold one open pool per behavioural case. + */ +const openDrivers: SqlDriver[] = []; + +afterEach(async () => { + while (openDrivers.length) { + const driver = openDrivers.pop(); + try { await driver?.disconnect(); } catch { /* noop */ } } - const adapter: any = (createObjectQLAdapterFactory(memoryReadEngine(driver)) as any)({} as any); - return { driver, adapter }; +}); + +async function seededAdapter() { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + openDrivers.push(driver); + // Real DDL through the driver's own path — the table every row below lands in + // is created by the backend, not conjured by a store on first write. + await driver.initObjects([SYS_USER]); + for (const row of SEED) await driver.create('sys_user', row); + const engine = sqlReadEngine(driver); + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any); + return { driver, engine, adapter }; } /** `findMany` with a single better-auth `contains` condition on `name`. */ @@ -89,6 +171,10 @@ function containsQuery(value: string) { } as any; } +// --------------------------------------------------------------------------- +// Face 1 — the contract: which ObjectQL operator the translation emits +// --------------------------------------------------------------------------- + describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => { let engine: IDataEngine; @@ -116,8 +202,8 @@ describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => { it('emits an operator every backend is required to evaluate', () => { // The whole point of the flip: `$contains` is in the protocol's runtime // allowlist, `$regex` never was — it survived only because this adapter - // produced it (driver-memory's `filter-refusal.ts` says so in as many - // words), which is why #5702's loud refusal is ordered after this PR. + // produced it, which is why #5702's loud refusal was ordered after #5812's + // flip and is now the thing face 3 reads. expect(FILTER_OPERATORS).toContain('$contains'); expect(FILTER_OPERATORS).not.toContain('$regex'); }); @@ -138,13 +224,18 @@ describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => { }); }); +// --------------------------------------------------------------------------- +// Face 2 — the behaviour: what a real backend answers +// --------------------------------------------------------------------------- + describe('[#5710] the comparand is a literal substring on a real backend', () => { it('does not read `.` as a wildcard — `a.b` matches `a.b`, not `axb`', async () => { const { adapter } = await seededAdapter(); const rows: any[] = await adapter.findMany(containsQuery('a.b')); // The pin, stated in both directions: the metacharacter row is NOT matched - // (a bare `$regex` matched it through `.`), and the literal row still is. + // (a bare `$regex` matched it through `.` on the memory backend, and is + // refused outright on this one), and the literal row still is. expect(rows.map((r) => r.name)).toEqual(['a.b']); }); @@ -155,6 +246,14 @@ describe('[#5710] the comparand is a literal substring on a real backend', () => // As a pattern, `^a` matched `a.b` and `axb`. As a substring, nothing here // contains the two characters `^a`. expect(rows).toEqual([]); + + // A control read on the SAME fixture, because "no rows" is the one answer a + // broken query and a correct one can both produce: an empty seed, a table + // that never got its DDL, or a predicate the backend silently dropped would + // all satisfy the assertion above. `a` is a substring of two of these three + // rows, so this says the store is live and the predicate really selects. + const control: any[] = await adapter.findMany(containsQuery('a')); + expect(control.map((r) => r.name).sort()).toEqual(['a.b', 'axb']); }); it('matches a value that is not a legal regex, instead of failing on it', async () => { @@ -174,3 +273,47 @@ describe('[#5710] the comparand is a literal substring on a real backend', () => expect(rows.map((r) => r.name)).toEqual(['axb']); }); }); + +// --------------------------------------------------------------------------- +// Face 3 — the discrimination: this backend can tell the two operators apart +// --------------------------------------------------------------------------- + +describe('[#5893] the backend refuses the operator face 2 must never see', () => { + it('refuses a bare `$regex` in the ADR-0112 envelope, naming its replacement', async () => { + const { engine } = await seededAdapter(); + + // Sent through the SAME engine facade the adapter reads on, so this is the + // literal path a regressed `convertWhere` would take — not a parallel one. + const err: any = await engine + .find('sys_user', { where: { name: { $regex: 'a.b' } } }) + .then(() => null, (e: unknown) => e); + + // `code` AND `status`, never a bare `rejects.toThrow()`: the defect has two + // fields and a throw-only assertion carries one bit. Before #5702 this call + // did not throw at ALL — it ANSWERED, with `['a.b']` — so an assertion that + // only says "the promise rejected" cannot separate "refused with the wrong + // envelope" from "did not refuse at all", which are exactly the two ways + // this guard can rot. + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + // The message is the operating instruction: which operator was refused, and + // what to write instead (#5702 prescribes the replacement, not a list). + expect(err.message).toContain('$regex'); + expect(err.message).toContain('$icontains'); + }); + + it('answers the operator the adapter DOES emit, on the same fixture', async () => { + const { engine } = await seededAdapter(); + + // The other half of the pair, and the reason this face is not just a + // driver-sql test living in the wrong package: refusing everything would + // satisfy the case above. `$contains` and `$regex` must be told APART by + // this backend — one answers, the other is refused — because that + // difference is the whole reason face 2 is allowed to live on sqlite + // (#5830 measured the world where it was not, and deferred the migration). + const rows = await engine.find('sys_user', { where: { name: { $contains: 'a.b' } } }); + + expect((rows as any[]).map((r) => r.name)).toEqual(['a.b']); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts b/packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts index dca988cf88..acc0d93059 100644 --- a/packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts +++ b/packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts @@ -60,11 +60,16 @@ * both ways in #5830: re-dropping the `not_in` arm turns the `not_in` pin red * on sqlite exactly as it did on memory. * - * Its sibling `auth-contains-filter.test.ts` is NOT interchangeable this way — - * driver-sql compiles `$regex` through the same `applyContainsLike` as - * `$contains` (`sql-driver.ts`, the `case '$regex':` fallthrough), so a SQL - * backend cannot tell #5710's defect from its fix. That file's disposition is - * #5830's open half; do not "finish the job" by copying this harness onto it. + * [#5893] Its sibling `auth-contains-filter.test.ts` was NOT interchangeable + * this way when the note above was written — driver-sql compiled `$regex` + * through the same `applyContainsLike` as `$contains` (`sql-driver.ts`, the + * `case '$regex':` fallthrough), so a SQL backend could not tell #5710's defect + * from its fix, and #5830 deferred it rather than create an always-green pin. + * #5702 (PR #6549) deleted the fallthrough and RETIRED the spelling: `$regex` + * is now refused by name in the ADR-0112 envelope (`INVALID_FILTER` / 400), so + * that file has moved onto this harness too and witnesses its defect through + * the refusal. It carries its own guard for the refusal, because that property + * — not a row difference — is what lets its behavioural pins fail at all. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; diff --git a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts index 25ecb3f182..d26745e928 100644 --- a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts +++ b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts @@ -43,9 +43,16 @@ /** * ⚠️ `@objectstack/driver-memory` is imported here ON PURPOSE, and this is the - * only PERMANENT test consumer of it in the repository. It is NOT a migration + * test consumer of it that #5704 RULED permanent. It is NOT a migration * leftover — do not "finish the job" by deleting or replacing it. * + * (This block used to say "the only permanent test consumer in the repository". + * That census expired without anyone editing it: `#6468`'s + * `autonumber-seed-cross-side-parity.integration.test.ts`, in this same + * package, imports the driver too and is not covered by #5704's ruling. Its + * disposition is filed as #6664. The sentence now claims what it can defend: a + * RULING about this file, not a count of the repository.) + * * Why it has to stay: the whole point of this file is a PRODUCT divergence * between two driver families — writing an undeclared field is rejected as a * WHOLE statement by the SQL family, and accepted verbatim by the schemaless @@ -61,32 +68,46 @@ * investment, and nothing here fixes or extends it. Ruling: #5704, maintainer * 2026-08-06, Q2 = B ("keep, in this one place, with a comment saying so"). * Consequence, also ruled there: `packages/runtime`'s `driver-memory` devDep - * stays for the long term — this import is its one and only consumer. + * stays for the long term. (That devDep now has a second importer in this + * package as well — #6468's autonumber parity test — so removing this file's + * import alone would no longer even drop the dependency. See #6664.) * * Everything else that used to look like a driver-memory test consumer was a * hand-written local stub whose NAME merely said "memory" — in packages that * do not even depend on the driver. #5704/#5784 renamed them all to - * `makeStubDriver`, precisely so that grepping for the driver lands here, and - * only here. + * `makeStubDriver`, precisely so that grepping for the driver lands on real + * consumers only. + * + * [#5830 / #5893] The identity lane's two arrivals are gone. Two consumers + * appeared in plugin-auth AFTER #5704's survey (#5812 and #5844); both have + * since been migrated to sqlite `:memory:`, and plugin-auth's + * `@objectstack/driver-memory` devDep is gone with them: * - * [#5830] "Only here" was briefly untrue and is being restored in two steps, - * so the grep is honest about what it finds today. Two consumers arrived in - * plugin-auth AFTER #5704's survey (#5812 and #5844, the identity lane): + * - `plugin-auth/src/auth-where-operator-coverage.test.ts` — migrated by + * #5830 (PR #5880). Its defect (#5813) was a DROPPED predicate, which any + * backend that really executes the filter witnesses. + * - `plugin-auth/src/auth-contains-filter.test.ts` — migrated by #5893, on + * the expiry condition #5830 wrote for it rather than on a second opinion. + * Its pin is #5710's `contains` → `$regex` flip, and while driver-sql still + * routed `$regex` through the same `applyContainsLike` as `$contains` (the + * `case '$regex':` fallthrough) a SQL witness answered identically either + * way — measured in #5830: with the defect restored, memory failed 3 + * behavioural pins and sqlite passed all 4, so migrating then would have + * produced pins that are green because nothing distinguishes them. #5702 + * (PR #6549) deleted that fallthrough and RETIRED the spelling: driver-sql + * now refuses `$regex` by name in the ADR-0112 envelope + * (`INVALID_FILTER` / 400), so the SQL arm witnesses the defect again — as + * a refusal rather than a different row set, which is the better reason + * #5830's expiry clause predicted. The migrated file carries its own guard + * for that property, so it cannot silently revert to an always-green pin. * - * - `plugin-auth/src/auth-where-operator-coverage.test.ts` — migrated to - * sqlite `:memory:` by #5830. Its defect (#5813) was a DROPPED predicate, - * which any backend that really executes the filter witnesses. - * - `plugin-auth/src/auth-contains-filter.test.ts` — still on driver-memory, - * pending a maintainer ruling, and NOT an oversight. Its pin is #5710's - * `contains` → `$regex` flip, and driver-sql routes `$regex` through the - * same `applyContainsLike` as `$contains` (the `case '$regex':` - * fallthrough in `sql-driver.ts`), so a SQL witness answers identically - * either way. Measured in #5830: with the defect restored, the memory - * backend fails 3 behavioural pins and a sqlite backend passes all 4. - * Migrating it would leave assertions that pass because nothing - * distinguishes them. Its disposition rides on #5702 (the driver-side - * `$regex` refusal) — once `$regex` is refused rather than aliased, the - * SQL arm can witness it and the file can move. + * What a grep for the driver's DECLARATIONS finds in `packages/` after that + * migration: this file, and #6468's `autonumber-seed-cross-side-parity` + * integration test (unruled — #6664). Nothing in plugin-auth. The prose + * MENTIONS that remain there — the identity-lane files explain the history + * above in their own comments, because a pin has to say what it used to be + * wrong about — are not consumers: retirement verification greps declarations, + * not mentions, which is the distinction that makes the grep usable at all. */ import { describe, it, expect, afterEach } from 'vitest'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22154ac3a7..06d9ce49cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,9 +1446,6 @@ importers: specifier: ^6.2.5 version: 6.2.7 devDependencies: - '@objectstack/driver-memory': - specifier: workspace:* - version: link:../../drivers/driver-memory '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql