diff --git a/.changeset/driver-options-bypass-tenant-audit.md b/.changeset/driver-options-bypass-tenant-audit.md new file mode 100644 index 0000000000..3ad6f128b8 --- /dev/null +++ b/.changeset/driver-options-bypass-tenant-audit.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": patch +"@objectstack/driver-sql": patch +"@objectstack/driver-sqlite-wasm": patch +"@objectstack/driver-memory": patch +--- + +fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311) + +Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever +read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors, +and most of what looked like sloppy test fixtures was the types being wrong. + +**`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a +long time without being on the schema: `SqlDriver.auditMissingTenant` reads it +to suppress the "tenant-scoped write without `tenantId`" warning, the driver's +own warning text tells callers to set it, `ObjectQLEngine` sets it for +system-context calls, and `service-settings` / `service-datasource` pass it on +every global-scope write. Because the schema never had it, the driver read it +through `(options as any)` and no caller was type-checked. The declaration +states the limit as well: it silences a diagnostic and MUST NOT change which +rows a write touches — suppressing an audit warning is not a permission. + +The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`, +all long since declared. Those reads now go through `DriverOptions`, so the next +undeclared option fails the build instead of hiding behind an existing cast. + +**`SqlDriver.findOne(object, id)` is removed.** An undeclared +`typeof query === 'string' | 'number'` branch accepted a bare id. It was on no +contract, nothing outside that package's own tests used it, and the other two +drivers answered the identical call differently — `MemoryDriver` spreads the +string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined` +and returns an arbitrary row. It also bypassed the shared `findRows()` path, so +it skipped field selection, temporal coercion, unknown-column recovery and the +`singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is: + +```ts +- await driver.findOne('task', 't1'); ++ await driver.findOne('task', { object: 'task', where: { id: 't1' } }); +``` + +**`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is +fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the +tenant column and to set or clear the sticky explicit-opt-out — but the +parameter type listed only `{ name, fields }`, so a caller that spelled the key +correctly was rejected while the driver read it anyway. +`registerExternalObject` already had it. + +**`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is +`.default('UTC')`, so the parsed type requires it and an authored literal does +not have it — the same two-tier split `QueryInput`/`QueryAST` already names on +the query side. `InMemoryDriver.create`/`bulkCreate` also declare their +`IDataDriver` return types; without them TS inferred the literal the method +builds and every other column of the created row disappeared from the caller's +view. + +One silent runtime bug fell out of the same pass: a driver test asked for +`orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and +the sort never reached SQL. The tuple spelling appears nowhere else. diff --git a/content/docs/references/data/driver.mdx b/content/docs/references/data/driver.mdx index 74236e59cb..74e6d31fed 100644 --- a/content/docs/references/data/driver.mdx +++ b/content/docs/references/data/driver.mdx @@ -98,6 +98,7 @@ const result = DriverCapabilities.parse(data); | **tenantIds** | `string[]` | optional | Union tenant access set (group posture): native read scoping widens to organization_id IN (...); inserts still stamp from tenantId | | **timezone** | `string` | optional | Business reference timezone (IANA) for date-dependent generation, e.g. autonumber date tokens | | **preserveAudit** | `boolean` | optional | Historical import: keep a supplied updated_at instead of force-stamping now (from ExecutionContext.preserveAudit) | +| **bypassTenantAudit** | `boolean` | optional | Suppress the driver tenant-audit warning for a deliberately global write on a tenant-scoped object (diagnostics only — never changes what the write touches) | --- diff --git a/packages/plugins/driver-memory/package.json b/packages/plugins/driver-memory/package.json index f330baaf53..24a94928fd 100644 --- a/packages/plugins/driver-memory/package.json +++ b/packages/plugins/driver-memory/package.json @@ -15,7 +15,8 @@ "scripts": { "build": "tsup --config ../../../tsup.config.ts", "dev": "tsc -w", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@objectstack/core": "workspace:*", diff --git a/packages/plugins/driver-memory/src/memory-analytics.test.ts b/packages/plugins/driver-memory/src/memory-analytics.test.ts index 45d8008562..1d42b13245 100644 --- a/packages/plugins/driver-memory/src/memory-analytics.test.ts +++ b/packages/plugins/driver-memory/src/memory-analytics.test.ts @@ -3,7 +3,21 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; -import type { Cube } from '@objectstack/spec/data'; +import { AnalyticsQuerySchema, defineCube } from '@objectstack/spec/data'; +import type { AnalyticsQuery, AnalyticsQueryInput, Cube } from '@objectstack/spec/data'; + +/** + * Author-tier literal → the parsed `AnalyticsQuery` the service contract takes. + * + * `timezone` is `.default('UTC')` on the schema, so it is optional to write and + * required on the parsed type — the two tiers are genuinely different types. A + * real query reaches `query()` through the schema (the REST layer parses the + * request body), so these tests take the same route rather than hand-writing + * the filled-in default: the parse IS the proof that the default lands. Until + * #4311 no tsc read this file, so 19 author-tier literals sat unnoticed in a + * parameter that had required `timezone` all along. + */ +const asQuery = (input: AnalyticsQueryInput): AnalyticsQuery => AnalyticsQuerySchema.parse(input); describe('MemoryAnalyticsService', () => { let driver: InMemoryDriver; @@ -160,10 +174,10 @@ describe('MemoryAnalyticsService', () => { describe('query', () => { it('should execute a simple count query', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'] - }); + })); expect(result.rows).toHaveLength(1); expect(result.rows[0]['orders.count']).toBe(5); @@ -173,11 +187,11 @@ describe('MemoryAnalyticsService', () => { }); it('should group by a dimension', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'], dimensions: ['orders.status'] - }); + })); expect(result.rows).toHaveLength(3); // completed, pending, cancelled @@ -187,11 +201,11 @@ describe('MemoryAnalyticsService', () => { }); it('should calculate sum aggregation', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.totalAmount'], dimensions: ['orders.customer'] - }); + })); const aliceRow = result.rows.find(r => r['orders.customer'] === 'Alice'); expect(aliceRow).toBeDefined(); @@ -199,11 +213,11 @@ describe('MemoryAnalyticsService', () => { }); it('should calculate average aggregation', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'products', measures: ['products.avgPrice'], dimensions: ['products.category'] - }); + })); const electronicsRow = result.rows.find(r => r['products.category'] === 'electronics'); expect(electronicsRow).toBeDefined(); @@ -211,10 +225,10 @@ describe('MemoryAnalyticsService', () => { }); it('should support multiple measures', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count', 'orders.totalAmount', 'orders.avgAmount'] - }); + })); expect(result.rows).toHaveLength(1); expect(result.rows[0]['orders.count']).toBe(5); @@ -223,11 +237,11 @@ describe('MemoryAnalyticsService', () => { }); it('should apply filters via canonical `where`', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count', 'orders.totalAmount'], where: { 'orders.status': 'completed' }, - }); + })); expect(result.rows).toHaveLength(1); expect(result.rows[0]['orders.count']).toBe(3); @@ -235,11 +249,11 @@ describe('MemoryAnalyticsService', () => { }); it('should accept FilterCondition (short-form equality)', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count', 'orders.totalAmount'], where: { status: 'completed' }, - }); + })); expect(result.rows).toHaveLength(1); expect(result.rows[0]['orders.count']).toBe(3); @@ -247,32 +261,32 @@ describe('MemoryAnalyticsService', () => { }); it('should accept FilterCondition with $in operator', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'], where: { status: { $in: ['completed', 'pending'] } }, - }); + })); expect(result.rows[0]['orders.count']).toBe(4); // 3 completed + 1 pending }); it('should accept FilterCondition with $gte operator', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'], where: { amount: { $gte: 200 } }, - }); + })); expect(result.rows[0]['orders.count']).toBe(2); // 200, 300 }); it('should support sorting', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.totalAmount'], dimensions: ['orders.customer'], order: { 'orders.totalAmount': 'desc' } - }); + })); expect(result.rows[0]['orders.customer']).toBe('Charlie'); // 300 expect(result.rows[1]['orders.customer']).toBe('Alice'); // 250 @@ -280,14 +294,14 @@ describe('MemoryAnalyticsService', () => { }); it('should support limit and offset', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'], dimensions: ['orders.customer'], order: { 'orders.customer': 'asc' }, limit: 2, offset: 1 - }); + })); expect(result.rows).toHaveLength(2); expect(result.rows[0]['orders.customer']).toBe('Bob'); @@ -299,7 +313,10 @@ describe('MemoryAnalyticsService', () => { // 'amount' of type 'sum'). Clients that build measure names from // (field, function) pairs send 'amount_sum' — the resolver should // accept that alias and produce the same aggregate value. - const aliasCube: Cube = { + // `defineCube` rather than a bare `: Cube` literal: `public` is + // `.default(false)`, so it is required on the parsed `Cube` type and + // absent here — the factory is the spec's own answer to that split. + const aliasCube = defineCube({ name: 'opps', title: 'Opps', sql: 'orders', @@ -309,14 +326,14 @@ describe('MemoryAnalyticsService', () => { dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, }, - }; + }); const aliasService = new MemoryAnalyticsService({ driver, cubes: [aliasCube] }); - const aliased = await aliasService.query({ + const aliased = await aliasService.query(asQuery({ cube: 'opps', measures: ['amount_sum'], dimensions: ['status'], - }); + })); const completed = aliased.rows.find(r => r.status === 'completed'); expect(completed).toBeDefined(); @@ -325,18 +342,18 @@ describe('MemoryAnalyticsService', () => { it('should throw error for unknown cube', async () => { await expect(async () => { - await service.query({ + await service.query(asQuery({ cube: 'unknown', measures: ['unknown.count'] - }); + })); }).rejects.toThrow('Cube not found: unknown'); }); it('should include SQL in result for debugging', async () => { - const result = await service.query({ + const result = await service.query(asQuery({ cube: 'orders', measures: ['orders.count'] - }); + })); expect(result.sql).toBeDefined(); expect(result.sql).toContain('orders'); @@ -345,10 +362,10 @@ describe('MemoryAnalyticsService', () => { describe('generateSql', () => { it('should generate SQL for a simple query', async () => { - const result = await service.generateSql({ + const result = await service.generateSql(asQuery({ cube: 'orders', measures: ['orders.count'] - }); + })); expect(result.sql).toContain('SELECT'); expect(result.sql).toContain('COUNT(*)'); @@ -356,45 +373,45 @@ describe('MemoryAnalyticsService', () => { }); it('should generate SQL with GROUP BY', async () => { - const result = await service.generateSql({ + const result = await service.generateSql(asQuery({ cube: 'orders', measures: ['orders.count'], dimensions: ['orders.status'] - }); + })); expect(result.sql).toContain('GROUP BY status'); }); it('should generate SQL with WHERE clause', async () => { - const result = await service.generateSql({ + const result = await service.generateSql(asQuery({ cube: 'orders', measures: ['orders.count'], where: { 'orders.status': 'completed' }, - }); + })); expect(result.sql).toContain('WHERE'); expect(result.sql).toContain('status'); }); it('should generate SQL with ORDER BY', async () => { - const result = await service.generateSql({ + const result = await service.generateSql(asQuery({ cube: 'orders', measures: ['orders.count'], dimensions: ['orders.status'], order: { 'orders.status': 'asc' } - }); + })); expect(result.sql).toContain('ORDER BY'); expect(result.sql).toContain('ASC'); }); it('should generate SQL with LIMIT and OFFSET', async () => { - const result = await service.generateSql({ + const result = await service.generateSql(asQuery({ cube: 'orders', measures: ['orders.count'], limit: 10, offset: 5 - }); + })); expect(result.sql).toContain('LIMIT 10'); expect(result.sql).toContain('OFFSET 5'); diff --git a/packages/plugins/driver-memory/src/memory-datetime-storage.test.ts b/packages/plugins/driver-memory/src/memory-datetime-storage.test.ts index 2d6d868ecb..cc093639b0 100644 --- a/packages/plugins/driver-memory/src/memory-datetime-storage.test.ts +++ b/packages/plugins/driver-memory/src/memory-datetime-storage.test.ts @@ -60,12 +60,12 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { it('stores every writer form as canonical UTC ISO text', async () => { await seedMixed(); - const raw = await driver.find('task', {} as any); + const raw = await driver.find('task', { object: 'task' }); for (const row of raw) { expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string'); expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); } - const midnight = raw.find((r: any) => r.id === 'd_midnight') as any; + const midnight = raw.find((r: any) => r.id === 'd_midnight'); expect(midnight.created_at).toBe('2026-07-28T00:00:00.000Z'); }); @@ -92,10 +92,10 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { it('keeps #3777/#4042 bound semantics on top of the converged storage', async () => { await seedMixed(); - const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any); + const gte = await driver.find('task', { object: 'task', where: { created_at: { $gte: '2026-07-28' } } }); expect(ids(gte)).toEqual(['d_midnight', 'd_next_day', 's_evening', 's_morning']); - const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } } as any); + const lt = await driver.find('task', { object: 'task', where: { created_at: { $lt: '2026-07-28' } } }); expect(ids(lt)).toEqual(['d_yesterday', 's_old']); const instant = await driver.find('task', { @@ -144,7 +144,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { // honest subject for an update-path assertion. await driver.create('task', { id: 'u1', due_at: '2026-04-19T10:00:00.000Z' }); await driver.update('task', 'u1', { due_at: new Date('2026-07-28T09:15:00Z') }); - const row: any = (await driver.find('task', { where: { id: 'u1' } } as any))[0]; + const row: any = (await driver.find('task', { object: 'task', where: { id: 'u1' } }))[0]; expect(row.due_at).toBe('2026-07-28T09:15:00.000Z'); // …and the converged value is reachable by a date window, which is the @@ -165,9 +165,9 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { ] as const) { await driver.create('task', { id, created_on }); } - const all = await driver.find('task', {} as any); + const all = await driver.find('task', { object: 'task' }); for (const row of all) expect(typeof (row as any).created_on).toBe('string'); - expect((all.find((r: any) => r.id === 'on_obj') as any).created_on).toBe('2026-07-28'); + expect((all.find((r: any) => r.id === 'on_obj')).created_on).toBe('2026-07-28'); const found = await driver.find('task', { where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }, @@ -177,7 +177,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { it('an undeclared object is left alone (no schema → no coercion)', async () => { await driver.create('freeform', { id: 'f1', when: new Date('2026-07-28T09:15:00Z') }); - const row: any = (await driver.find('freeform', {} as any))[0]; + const row: any = (await driver.find('freeform', { object: 'freeform' }))[0]; expect(row.when).toBeInstanceOf(Date); }); }); diff --git a/packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts b/packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts index 6a19a275c4..d46ad87846 100644 --- a/packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts +++ b/packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts @@ -58,13 +58,13 @@ describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => }); it('$gte / $gt / $lt keep their midnight anchoring', async () => { - const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any); + const gte = await driver.find('task', { object: 'task', where: { created_at: { $gte: '2026-07-28' } } }); expect(ids(gte)).toEqual(['t_evening', 't_midnight', 't_morning']); - const gt = await driver.find('task', { where: { created_at: { $gt: '2026-07-28' } } } as any); + const gt = await driver.find('task', { object: 'task', where: { created_at: { $gt: '2026-07-28' } } }); expect(ids(gt)).toEqual(['t_evening', 't_midnight', 't_morning']); // string '…T00:00' > '2026-07-28' - const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } } as any); + const lt = await driver.find('task', { object: 'task', where: { created_at: { $lt: '2026-07-28' } } }); expect(ids(lt)).toEqual(['t_old', 't_yesterday']); }); diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index 77696cfa53..229f13ad86 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -377,7 +377,12 @@ export class InMemoryDriver implements IDataDriver { return result; } - async create(object: string, data: Record, options?: DriverOptions) { + // The `IDataDriver.create` return type, spelled out: without it TS infers the + // literal built below (`{ id, created_at, updated_at }`) and every OTHER + // column of the created row vanishes from the caller's view (#4311 — the + // driver's own tests read `.name` off a create() result and no tsc had ever + // told them it wasn't there). + async create(object: string, data: Record, options?: DriverOptions): Promise> { this.logger.debug('Create operation', { object, hasData: !!data }); const table = this.getTable(object); @@ -482,7 +487,7 @@ export class InMemoryDriver implements IDataDriver { // Bulk Operations // =================================== - async bulkCreate(object: string, dataArray: Record[], options?: DriverOptions) { + async bulkCreate(object: string, dataArray: Record[], options?: DriverOptions): Promise[]> { this.logger.debug('BulkCreate operation', { object, count: dataArray.length }); const results = await Promise.all(dataArray.map(data => this.create(object, data, options))); this.logger.debug('BulkCreate completed', { object, count: results.length }); diff --git a/packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts b/packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts index 2c588ad624..42c05d9066 100644 --- a/packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts +++ b/packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts @@ -22,6 +22,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import type { FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; const TABLE = 'vocab_probe'; @@ -37,8 +38,11 @@ describe('InMemoryDriver filter vocabulary ↔ VALID_AST_OPERATORS', () => { }); /** Operators are exercised through `find`, the path a real query takes. */ + // Cast deliberately: several `where` shapes below are ones the AST gate + // refuses, fed in to prove the driver throws instead of silently dropping + // the condition. `unknown` is the honest parameter type. const find = (where: unknown) => - driver.find(TABLE, { object: TABLE, fields: ['id'], where } as any); + driver.find(TABLE, { object: TABLE, fields: ['id'], where: where as FilterCondition }); it('reads a non-empty operator set from the spec', () => { // Guards every assertion below from passing vacuously. diff --git a/packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts b/packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts index 79f85b92f3..9947a083da 100644 --- a/packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts +++ b/packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts @@ -77,7 +77,7 @@ describe('InMemoryDriver — paged reads are a partition of the result set (obje paged.push(...page); } - const whole = await driver.find('ticket', { orderBy: [...testCase.orderBy] } as any); + const whole = await driver.find('ticket', { object: 'ticket', orderBy: [...testCase.orderBy] }); expect(paged.map((r) => r.id)).toEqual(whole.map((r: any) => r.id)); }); } diff --git a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts index 60a999e8b1..c8a415b1d1 100644 --- a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts +++ b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts @@ -103,14 +103,14 @@ describe('driver-memory — temporal conformance', () => { for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); if (c.tokenFilter) { it(`${c.name} — via relative tokens`, async () => { - const rows = await driver.find('conformance', { where: resolveTokens(c.tokenFilter) } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: resolveTokens(c.tokenFilter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -141,7 +141,7 @@ describe('driver-memory — Field.time conformance', () => { for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -164,7 +164,7 @@ describe('driver-memory — temporal conformance on rows that predate the schema }); it('converged every pre-schema row to the storage canon (the premise, so the sweep cannot pass vacuously)', async () => { - const rows = await driver.find('conformance', {} as any); + const rows = await driver.find('conformance', { object: 'conformance' }); expect(rows).toHaveLength(TEMPORAL_ROWS.length); for (const row of rows as any[]) { const expected = TEMPORAL_ROWS.find((r) => r.id === row.id)!; @@ -179,7 +179,7 @@ describe('driver-memory — temporal conformance on rows that predate the schema // already swept above — a divergence here is a convergence bug by construction. for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -199,7 +199,7 @@ describe('driver-memory — Field.time conformance on rows that predate the sche }); it('converged every pre-schema wall clock to the storage canon (the premise)', async () => { - const rows = await driver.find('time_conformance', {} as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance' }); expect(rows).toHaveLength(TEMPORAL_TIME_ROWS.length); for (const row of rows as any[]) { const expected = TEMPORAL_TIME_ROWS.find((r) => r.id === row.id)!; @@ -209,7 +209,7 @@ describe('driver-memory — Field.time conformance on rows that predate the sche for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index fac695d484..3150c1d7a1 100644 --- a/packages/plugins/driver-sql/package.json +++ b/packages/plugins/driver-sql/package.json @@ -15,7 +15,8 @@ "scripts": { "build": "tsup --config ../../../tsup.config.ts", "dev": "tsc -w", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@objectstack/core": "workspace:*", diff --git a/packages/plugins/driver-sql/src/sql-driver-advanced.test.ts b/packages/plugins/driver-sql/src/sql-driver-advanced.test.ts index 1fa42c9379..c1f8d80b9e 100644 --- a/packages/plugins/driver-sql/src/sql-driver-advanced.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-advanced.test.ts @@ -139,33 +139,33 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { expect(result).toBeDefined(); expect(result.length).toBe(3); - const count = await driver.count('orders', {}); + const count = await driver.count('orders', { object: 'orders' }); expect(count).toBe(8); }); it('should update many records', async () => { - const result = await driver.updateMany('orders', { where: { status: 'pending' } } as any, { status: 'processing' }); + const result = await driver.updateMany('orders', { object: 'orders', where: { status: 'pending' } }, { status: 'processing' }); expect(result).toBeGreaterThan(0); - const results = await driver.find('orders', { where: { status: 'processing' } }); + const results = await driver.find('orders', { object: 'orders', where: { status: 'processing' } }); expect(results.length).toBe(1); }); it('should delete many records', async () => { - const result = await driver.deleteMany('orders', { where: { status: 'cancelled' } } as any); + const result = await driver.deleteMany('orders', { object: 'orders', where: { status: 'cancelled' } }); expect(result).toBe(1); - const remaining = await driver.count('orders', {}); + const remaining = await driver.count('orders', { object: 'orders' }); expect(remaining).toBe(4); }); it('should handle empty bulk update and delete', async () => { - const result = await driver.updateMany('orders', { where: { status: 'nonexistent' } } as any, { status: 'updated' }); + const result = await driver.updateMany('orders', { object: 'orders', where: { status: 'nonexistent' } }, { status: 'updated' }); expect(result).toBe(0); - const deleteResult = await driver.deleteMany('orders', { where: { id: 'nonexistent' } } as any); + const deleteResult = await driver.deleteMany('orders', { object: 'orders', where: { id: 'nonexistent' } }); expect(deleteResult).toBe(0); }); }); @@ -190,7 +190,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); - const result = await driver.findOne('orders', 'trx1' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'trx1' } }); expect(result).toBeDefined(); expect(result.customer).toBe('TxUser'); } catch (e) { @@ -218,7 +218,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.rollbackTransaction(trx); - const result = await driver.findOne('orders', 'trx2' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'trx2' } }); expect(result).toBeNull(); } catch (e) { await driver.rollbackTransaction(trx); @@ -249,13 +249,13 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); - const created = await driver.findOne('orders', 'trx3' as any); + const created = await driver.findOne('orders', { object: 'orders', where: { id: 'trx3' } }); expect(created).toBeDefined(); - const updated = await driver.findOne('orders', '1' as any); + const updated = await driver.findOne('orders', { object: 'orders', where: { id: '1' } }); expect(updated.status).toBe('shipped'); - const deleted = await driver.findOne('orders', '5' as any); + const deleted = await driver.findOne('orders', { object: 'orders', where: { id: '5' } }); expect(deleted).toBeNull(); } catch (e) { await driver.rollbackTransaction(trx); @@ -266,12 +266,12 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { describe('Edge Cases and Error Handling', () => { it('should handle empty filters gracefully', async () => { - const results = await driver.find('orders', { where: {} }); + const results = await driver.find('orders', { object: 'orders', where: {} }); expect(results.length).toBe(5); }); it('should handle undefined query parameters', async () => { - const results = await driver.find('orders', {}); + const results = await driver.find('orders', { object: 'orders' }); expect(results.length).toBe(5); }); @@ -284,7 +284,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.create('nullable_test', { id: '1', name: null, value: null }); - const result = await driver.findOne('nullable_test', '1' as any); + const result = await driver.findOne('nullable_test', { object: 'nullable_test', where: { id: '1' } }); expect(result).toBeDefined(); expect(result.name).toBeNull(); expect(result.value).toBeNull(); @@ -292,6 +292,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { it('should handle pagination with offset and limit', async () => { const page1 = await driver.find('orders', { + object: 'orders', orderBy: [{ field: 'id', order: 'asc' }], offset: 0, limit: 2, @@ -300,6 +301,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { expect(page1[0].id).toBe('1'); const page2 = await driver.find('orders', { + object: 'orders', orderBy: [{ field: 'id', order: 'asc' }], offset: 2, limit: 2, @@ -309,12 +311,13 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { }); it('should handle offset beyond total records', async () => { - const results = await driver.find('orders', { offset: 100, limit: 10 }); + const results = await driver.find('orders', { object: 'orders', offset: 100, limit: 10 }); expect(results.length).toBe(0); }); it('should handle complex nested filters', async () => { const results = await driver.find('orders', { + object: 'orders', where: { $or: [ { $and: [{ status: 'completed' }, { amount: { $gt: 100 } }] }, @@ -335,6 +338,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { it('should handle contains filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { product: { $contains: 'top' } }, }); @@ -344,6 +348,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { it('should handle in filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { status: { $in: ['completed', 'pending'] } }, }); @@ -352,6 +357,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { it('should handle nin (not in) filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { status: { $nin: ['cancelled'] } }, }); @@ -359,14 +365,14 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { }); it('should handle findOne with query parameter', async () => { - const result = await driver.findOne('orders', { where: { customer: 'Charlie' } }); + const result = await driver.findOne('orders', { object: 'orders', where: { customer: 'Charlie' } }); expect(result).toBeDefined(); expect(result.customer).toBe('Charlie'); }); it('should return null for non-existent record', async () => { - const result = await driver.findOne('orders', 'nonexistent' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'nonexistent' } }); expect(result).toBeNull(); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts b/packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts index 048fa982a7..0ccfc8866e 100644 --- a/packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts @@ -71,7 +71,10 @@ describe('temporal values leaving aggregate()/distinct() (#3797)', () => { /** What the same column looks like through the path that always formatted it. */ const viaFind = async (field: string) => { - const rows = await driver.find(TABLE, { orderBy: [['id', 'asc']] } as any); + // `orderBy: [['id', 'asc']]` until #4311. The driver reads `item.field`, + // which a tuple does not have, so the sort was silently dropped — this + // helper had been reading whatever order the rows came back in. + const rows = await driver.find(TABLE, { object: TABLE, orderBy: [{ field: 'id', order: 'asc' }] }); return rows.map((r: any) => r[field]); }; diff --git a/packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts b/packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts index cff141738e..a1e01edda9 100644 --- a/packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts @@ -63,7 +63,7 @@ describe('SqlDriver array/object field persistence', () => { }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true }); + const row = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z1' } }, { bypassTenantAudit: true }); expect(row.tags).toEqual(['x', 'y']); expect(row.ms).toEqual(['red', 'green']); expect(row.cbs).toEqual(['email', 'push']); @@ -76,13 +76,13 @@ describe('SqlDriver array/object field persistence', () => { it('updates an array field to a new array', async () => { await driver.create('zoo', { id: 'z2', name: 'B', tags: ['a'] }, { bypassTenantAudit: true }); await driver.update('zoo', 'z2', { tags: ['a', 'b', 'c'] }, { bypassTenantAudit: true }); - const row = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true }); + const row = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z2' } }, { bypassTenantAudit: true }); expect(row.tags).toEqual(['a', 'b', 'c']); }); it('does not crash on an empty array', async () => { await driver.create('zoo', { id: 'z3', name: 'C', ms: [] }, { bypassTenantAudit: true }); - const row = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true }); + const row = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z3' } }, { bypassTenantAudit: true }); expect(row.ms).toEqual([]); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts index 6cc3106487..4e9d7ed27f 100644 --- a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts @@ -43,8 +43,8 @@ describe('SqlDriver auto_number format tokens', () => { await driver.initObjects([ { name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } }, ]); - const r1 = await driver.create('andon', {}, { timezone: 'UTC' } as any); - const r2 = await driver.create('andon', {}, { timezone: 'UTC' } as any); + const r1 = await driver.create('andon', {}, { timezone: 'UTC' }); + const r2 = await driver.create('andon', {}, { timezone: 'UTC' }); expect(r1.andon_no).toBe(`AD${utcYmd()}0001`); expect(r2.andon_no).toBe(`AD${utcYmd()}0002`); }); @@ -57,7 +57,7 @@ describe('SqlDriver auto_number format tokens', () => { const k = (driver as any).knex; await k('andon').insert({ id: 'old', andon_no: 'AD202001010099' }); - const r = await driver.create('andon', {}, { timezone: 'UTC' } as any); + const r = await driver.create('andon', {}, { timezone: 'UTC' }); expect(r.andon_no).toBe(`AD${utcYmd()}0001`); // fresh scope, not 0100 }); @@ -113,9 +113,9 @@ describe('SqlDriver auto_number format tokens', () => { }, }, ]); - const a = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any); - const b = await driver.create('plan', { organization_id: 'orgB', line: 'PROD' }, { timezone: 'UTC' } as any); - const a2 = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any); + const a = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' }); + const b = await driver.create('plan', { organization_id: 'orgB', line: 'PROD' }, { timezone: 'UTC' }); + const a2 = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' }); expect(a.plan_no).toBe(`PROD${utcYmd()}001`); expect(a2.plan_no).toBe(`PROD${utcYmd()}002`); diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts index 0864f92113..36e2153e07 100644 --- a/packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts @@ -73,12 +73,12 @@ describe('SqlDriver auto_number in-transaction (deadlock regression)', () => { // /batch endpoint wraps operations. Must commit, not hang. const trx = await driver!.beginTransaction(); const r1 = await withTimeout( - driver!.create('contract', { organization_id: 'org_x', name: 'A' }, { transaction: trx } as any), + driver!.create('contract', { organization_id: 'org_x', name: 'A' }, { transaction: trx }), 6000, 'batch create #1 (autonumber)', ); const r2 = await withTimeout( - driver!.create('contract', { organization_id: 'org_x', name: 'B' }, { transaction: trx } as any), + driver!.create('contract', { organization_id: 'org_x', name: 'B' }, { transaction: trx }), 6000, 'batch create #2 (autonumber)', ); @@ -106,7 +106,7 @@ describe('SqlDriver auto_number in-transaction (deadlock regression)', () => { const trx = await driver!.beginTransaction(); const r1 = await withTimeout( - driver!.create('contract', { organization_id: 'org_cold', name: 'C' }, { transaction: trx } as any), + driver!.create('contract', { organization_id: 'org_cold', name: 'C' }, { transaction: trx }), 6000, 'cold-cache create inside tx', ); diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts index e691e0e2e9..6381d23853 100644 --- a/packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts @@ -229,9 +229,9 @@ describe('SqlDriver auto_number sequence', () => { ]); // No organization_id on the row, but provided via DriverOptions. - const r1 = await driver.create('contract', {}, { tenantId: 'org_options' } as any); - const r2 = await driver.create('contract', {}, { tenantId: 'org_options' } as any); - const r3 = await driver.create('contract', {}, { tenantId: 'org_other' } as any); + const r1 = await driver.create('contract', {}, { tenantId: 'org_options' }); + const r2 = await driver.create('contract', {}, { tenantId: 'org_options' }); + const r3 = await driver.create('contract', {}, { tenantId: 'org_other' }); expect(r1.contract_number).toBe('CTR-0001'); expect(r2.contract_number).toBe('CTR-0002'); diff --git a/packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts b/packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts index 72292b154a..c7624bf783 100644 --- a/packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts @@ -101,13 +101,13 @@ describe('bare-day $lte on Field.datetime — the #3777 repro', () => { }); it('$gte / $gt / $lt keep their midnight anchoring (the issue-table rows marked correct)', async () => { - const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any); + const gte = await driver.find('task', { object: 'task', where: { created_at: { $gte: '2026-07-28' } } }); expect(ids(gte)).toEqual(['t_evening', 't_midnight', 't_morning']); - const gt = await driver.find('task', { where: { created_at: { $gt: '2026-07-28' } } } as any); + const gt = await driver.find('task', { object: 'task', where: { created_at: { $gt: '2026-07-28' } } }); expect(ids(gt)).toEqual(['t_evening', 't_morning']); // excludes the exact-midnight row - const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } } as any); + const lt = await driver.find('task', { object: 'task', where: { created_at: { $lt: '2026-07-28' } } }); expect(ids(lt)).toEqual(['t_old', 't_yesterday']); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts b/packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts index 3527249a22..7b1fa32839 100644 --- a/packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts @@ -73,7 +73,7 @@ function suite(dialect: 'pg' | 'mysql', url: string | undefined) { it('a defaulted insert round-trips a valid canonical calendar day', async () => { await driver.create(TABLE, { id: 'x', label: 'x' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(TABLE, 'x', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'x' } }, { bypassTenantAudit: true }); expect(String(row.due_on)).toMatch(/^\d{4}-\d{2}-\d{2}$/); // The UTC calendar day only ever differs from this probe's own UTC clock // across a midnight boundary — accept today or the day either side rather diff --git a/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts b/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts index 7e7f248607..b486adb174 100644 --- a/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts @@ -49,7 +49,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { id: 'd1', name: 'A', close_date: new Date('2026-07-15T17:24:56.533Z') }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('deal', 'd1', { bypassTenantAudit: true }); + const row = await driver.findOne('deal', { object: 'deal', where: { id: 'd1' } }, { bypassTenantAudit: true }); expect(row.close_date).toBe('2026-07-15'); }); @@ -59,7 +59,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { id: 'd2', name: 'B', close_date: '2026-07-15T17:24:56.533Z' }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('deal', 'd2', { bypassTenantAudit: true }); + const row = await driver.findOne('deal', { object: 'deal', where: { id: 'd2' } }, { bypassTenantAudit: true }); expect(row.close_date).toBe('2026-07-15'); }); @@ -69,7 +69,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { id: 'd3', name: 'C', close_date: '2026-07-15' }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('deal', 'd3', { bypassTenantAudit: true }); + const row = await driver.findOne('deal', { object: 'deal', where: { id: 'd3' } }, { bypassTenantAudit: true }); expect(row.close_date).toBe('2026-07-15'); }); @@ -79,7 +79,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { id: 'd4', name: 'D', signed_at: new Date('2026-03-20T12:34:56.000Z') }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('deal', 'd4', { bypassTenantAudit: true }); + const row = await driver.findOne('deal', { object: 'deal', where: { id: 'd4' } }, { bypassTenantAudit: true }); // datetime must retain its wall-clock time — never sliced to YYYY-MM-DD. expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z'); }); @@ -92,7 +92,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { id: 'd5', name: 'E', close_date: new Date('2026-07-15T17:24:56.533Z') }, { bypassTenantAudit: true }, ); - const rows = await driver.find('deal', { where: { close_date: '2026-07-15' } }); + const rows = await driver.find('deal', { object: 'deal', where: { close_date: '2026-07-15' } }); expect(rows.map((r: any) => r.id)).toEqual(['d5']); }); @@ -100,7 +100,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( await driver.create('deal', { id: 'd6', name: 'F', close_date: new Date('2026-07-15T08:00:00Z') }, { bypassTenantAudit: true }); await driver.create('deal', { id: 'd7', name: 'G', close_date: '2026-07-16' }, { bypassTenantAudit: true }); await driver.create('deal', { id: 'd8', name: 'H', close_date: '2026-07-17' }, { bypassTenantAudit: true }); - const rows = await driver.find('deal', { where: { close_date: { $in: ['2026-07-15', '2026-07-17'] } } }); + const rows = await driver.find('deal', { object: 'deal', where: { close_date: { $in: ['2026-07-15', '2026-07-17'] } } }); expect(rows.map((r: any) => r.id).sort()).toEqual(['d6', 'd8']); }); @@ -108,7 +108,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( await driver.create('deal', { id: 'r1', close_date: '2025-01-15' }, { bypassTenantAudit: true }); await driver.create('deal', { id: 'r2', close_date: '2026-03-20' }, { bypassTenantAudit: true }); await driver.create('deal', { id: 'r3', close_date: '2026-05-25' }, { bypassTenantAudit: true }); - const rows = await driver.find('deal', { where: { close_date: { $gte: '2026-01-01', $lt: '2026-05-01' } } }); + const rows = await driver.find('deal', { object: 'deal', where: { close_date: { $gte: '2026-01-01', $lt: '2026-05-01' } } }); expect(rows.map((r: any) => r.id)).toEqual(['r2']); }); @@ -119,7 +119,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( await (driver as any).knex('deal').insert({ id: 'legacy', name: 'L', close_date: '2026-08-15T17:24:56.533Z' }); // Read-side repair: the returned value is date-only with no migration. - const row = await driver.findOne('deal', 'legacy', { bypassTenantAudit: true }); + const row = await driver.findOne('deal', { object: 'deal', where: { id: 'legacy' } }, { bypassTenantAudit: true }); expect(row.close_date).toBe('2026-08-15'); // …but the value still stored in SQL keeps its time, so a SQL equality @@ -127,13 +127,13 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( // ADR-0053 calls out: read-repair fixes display/read, and an optional // one-time migration (or any write through the normalized path) rewrites // legacy rows at rest. - const beforeRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } }); + const beforeRewrite = await driver.find('deal', { object: 'deal', where: { close_date: '2026-08-15' } }); expect(beforeRewrite.map((r: any) => r.id)).toEqual([]); // Rewriting through the normalized write path (formatInput) collapses the // stored value to date-only, after which the equality filter matches. await driver.update('deal', 'legacy', { close_date: '2026-08-15' }, { bypassTenantAudit: true }); - const afterRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } }); + const afterRewrite = await driver.find('deal', { object: 'deal', where: { close_date: '2026-08-15' } }); expect(afterRewrite.map((r: any) => r.id)).toEqual(['legacy']); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts b/packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts index 8ac02bb52b..7d32b25824 100644 --- a/packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts @@ -100,7 +100,7 @@ describe('Field.datetime writes land in ONE canonical form (#3912)', () => { ); } - const rows = await driver.find('evt', { orderBy: [{ field: 'at', order: 'asc' }] }); + const rows = await driver.find('evt', { object: 'evt', orderBy: [{ field: 'at', order: 'asc' }] }); expect(rows.map((r: any) => r.id)).toEqual(instants.map(([id]) => id)); // …and the raw column sorts identically, i.e. the DB did the ordering. @@ -188,12 +188,12 @@ describe('backfillCanonicalDatetimes converges a legacy database (#3912)', () => // it fast. Both must agree, or the migration would be observable as a change // in results — which is exactly what it must never be. const window = { at: { $gte: '2026-03-20T00:00:00.000Z', $lte: '2026-03-21T00:00:00.000Z' } }; - const before = (await driver.find('evt', { where: window })).map((r: any) => r.id).sort(); + const before = (await driver.find('evt', { object: 'evt', where: window })).map((r: any) => r.id).sort(); expect(before).toEqual(['canon', 'epoch', 'naive', 'offset']); await (driver as any).backfillCanonicalDatetimes('evt', true); - const after = (await driver.find('evt', { where: window })).map((r: any) => r.id).sort(); + const after = (await driver.find('evt', { object: 'evt', where: window })).map((r: any) => r.id).sort(); expect(after).toEqual(before); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts b/packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts index c3c2753b1e..840e08e016 100644 --- a/packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts @@ -68,6 +68,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches $gte against an ISO date string', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: { $gte: '2026-01-01' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -76,6 +77,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches a $gte / $lt window — the dashboard dateRange shape', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: { $gte: '2026-01-01', $lt: '2026-05-01' } }, }); expect(rows.map((r: any) => r.id)).toEqual(['l2']); @@ -83,11 +85,13 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches a full ISO timestamp comparand, to the millisecond', async () => { const inclusive = await driver.find('lead', { + object: 'lead', where: { created_date: { $gte: '2026-05-25T08:30:15.250Z' } }, }); expect(inclusive.map((r: any) => r.id)).toEqual(['l3']); const exclusive = await driver.find('lead', { + object: 'lead', where: { created_date: { $gt: '2026-05-25T08:30:15.250Z' } }, }); expect(exclusive).toEqual([]); @@ -95,6 +99,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches a JS Date comparand against a TEXT-stored row', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: { $gte: new Date('2026-05-01T00:00:00Z') } }, }); expect(rows.map((r: any) => r.id)).toEqual(['l3']); @@ -102,6 +107,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches equality on the exact stored instant', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: '2026-03-20T12:00:00.000Z' }, }); expect(rows.map((r: any) => r.id)).toEqual(['l2']); @@ -109,17 +115,20 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches $between, $in and $nin', async () => { const between = await driver.find('lead', { + object: 'lead', where: { created_date: { $between: ['2026-01-01', '2026-04-01'] } }, }); expect(between.map((r: any) => r.id)).toEqual(['l2']); const isIn = await driver.find('lead', { + object: 'lead', where: { created_date: { $in: ['2026-03-20T12:00:00.000Z', '2026-05-25T08:30:15.250Z'] } }, orderBy: [{ field: 'id', order: 'asc' }], }); expect(isIn.map((r: any) => r.id)).toEqual(['l2', 'l3']); const notIn = await driver.find('lead', { + object: 'lead', where: { created_date: { $nin: ['2026-03-20T12:00:00.000Z'] } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -128,16 +137,18 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches $ne and the null predicates', async () => { const ne = await driver.find('lead', { + object: 'lead', where: { created_date: { $ne: '2026-03-20T12:00:00.000Z' } }, orderBy: [{ field: 'id', order: 'asc' }], }); expect(ne.map((r: any) => r.id)).toEqual(['l1', 'l3']); await driver.create('lead', { id: 'l4', name: 'Undated' }, { bypassTenantAudit: true }); - const missing = await driver.find('lead', { where: { created_date: { $null: true } } }); + const missing = await driver.find('lead', { object: 'lead', where: { created_date: { $null: true } } }); expect(missing.map((r: any) => r.id)).toEqual(['l4']); const present = await driver.find('lead', { + object: 'lead', where: { created_date: { $null: false } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -153,6 +164,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('matches inside an $or branch', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { $or: [{ created_date: { $lt: '2025-06-01' } }, { name: 'Newer' }] }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -161,6 +173,7 @@ describe('SqlDriver datetime filters on ISO-TEXT-stored columns (#3912)', () => it('leaves the Field.date column on its own YYYY-MM-DD rule', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { closed_on: { $gte: '2026-01-01' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -209,6 +222,7 @@ describe('SqlDriver datetime filters on the created_at audit column (#3912)', () expect(stamped[0]).toEqual({ c: 'text', u: 'text' }); const matched = await driver.find('ticket', { + object: 'ticket', where: { created_at: { $gte: '2000-01-01' } }, }); expect(matched.map((r: any) => r.id)).toEqual(['t1']); @@ -216,6 +230,7 @@ describe('SqlDriver datetime filters on the created_at audit column (#3912)', () // …and a window that starts after the stamp excludes it, so the match above // is a real comparison rather than "the predicate was dropped". const future = await driver.find('ticket', { + object: 'ticket', where: { created_at: { $gte: '2999-01-01' } }, }); expect(future).toEqual([]); @@ -260,6 +275,7 @@ describe('SqlDriver datetime filters on a legacy MIXED-storage column (#3912)', it('returns rows of BOTH forms from one window filter', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: { $gte: '2026-01-01' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -268,6 +284,7 @@ describe('SqlDriver datetime filters on a legacy MIXED-storage column (#3912)', it('excludes rows of BOTH forms that fall outside the window', async () => { const rows = await driver.find('lead', { + object: 'lead', where: { created_date: { $lt: '2026-01-01' } }, orderBy: [{ field: 'id', order: 'asc' }], }); diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts b/packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts index ff609a89fb..3e3226365e 100644 --- a/packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts @@ -62,6 +62,7 @@ describe('SqlDriver datetime filter coercion', () => { it('matches datetime $gte against an ISO date string', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { published_at: { $gte: '2026-01-01' } }, orderBy: [{ field: 'published_at', order: 'asc' }], }); @@ -70,6 +71,7 @@ describe('SqlDriver datetime filter coercion', () => { it('matches datetime range with $gte / $lt', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { published_at: { $gte: '2026-01-01', $lt: '2026-05-01' } }, }); expect(rows.map((r: any) => r.id)).toEqual(['p2']); @@ -77,6 +79,7 @@ describe('SqlDriver datetime filter coercion', () => { it('accepts a full ISO timestamp', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { published_at: { $gte: '2026-05-25T00:00:00.000Z' } }, }); expect(rows.map((r: any) => r.id)).toEqual(['p3']); @@ -84,6 +87,7 @@ describe('SqlDriver datetime filter coercion', () => { it('accepts a JS Date object', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { published_at: { $gte: new Date('2026-01-01T00:00:00Z') } }, }); expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']); @@ -92,6 +96,7 @@ describe('SqlDriver datetime filter coercion', () => { it('accepts a numeric epoch millisecond value', async () => { const ms = Date.parse('2026-01-01T00:00:00Z'); const rows = await driver.find('publication', { + object: 'publication', where: { published_at: { $gte: ms } }, }); expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']); @@ -99,6 +104,7 @@ describe('SqlDriver datetime filter coercion', () => { it('still filters non-date columns normally', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { views: { $gte: 200 } }, }); expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']); @@ -106,6 +112,7 @@ describe('SqlDriver datetime filter coercion', () => { it('matches date (YYYY-MM-DD) columns with ISO comparand', async () => { const rows = await driver.find('publication', { + object: 'publication', where: { period_start: { $gte: '2026-01-01' } }, }); expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']); diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts b/packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts index 6c3c6e87d4..71c8bbeba4 100644 --- a/packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts @@ -130,7 +130,7 @@ describe.skipIf(!URL)('Field.datetime on MySQL (#3942)', () => { it('anchors a bare calendar-day comparand to UTC midnight, like the other dialects', async () => { await driver.create(TABLE, { id: 'b1', label: 'x', at: BOUNDARY }, { bypassTenantAudit: true }); const day = async (from: string, to: string) => - (await driver.find(TABLE, { where: { at: { $gte: from, $lt: to } } })).map((r: any) => r.id); + (await driver.find(TABLE, { object: TABLE, where: { at: { $gte: from, $lt: to } } })).map((r: any) => r.id); // 20:00Z belongs to 2026-03-20 in UTC; on a `+08:00` server read as local // midnight it would fall on the 21st — the divergence #3912 measured on PG. @@ -140,7 +140,7 @@ describe.skipIf(!URL)('Field.datetime on MySQL (#3942)', () => { it('round-trips the instant through a read', async () => { await driver.create(TABLE, { id: 'r', label: 'r', at: MIDDAY }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(TABLE, 'r', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'r' } }, { bypassTenantAudit: true }); expect(new Date(row.at).toISOString()).toBe(MIDDAY); }); }); @@ -219,7 +219,7 @@ describe.skipIf(!URL)('MySQL TIMESTAMP → DATETIME(3) migration (#3942)', () => { name: LEGACY, fields: { label: { type: 'string' }, at: { type: 'datetime' } } }, ]); await driver.create(LEGACY, { id: 'fresh', label: 'f' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(LEGACY, 'fresh', { bypassTenantAudit: true }); + const row: any = await driver.findOne(LEGACY, { object: LEGACY, where: { id: 'fresh' } }, { bypassTenantAudit: true }); expect(row.created_at ?? null, 'created_at must still default').not.toBeNull(); }); }); @@ -319,7 +319,7 @@ async function columnTypes(driver: SqlDriver, table: string): Promise { - const row: any = await driver.findOne(table, id, { bypassTenantAudit: true }); + const row: any = await driver.findOne(table, { object: table, where: { id } }, { bypassTenantAudit: true }); return new Date(row.at).toISOString(); } diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts b/packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts index 6d62c1a4e9..0b18ce1ca8 100644 --- a/packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts @@ -104,7 +104,7 @@ describe.skipIf(!URL)('Field.datetime on Postgres is timezone-independent (#3912 await driver.create(TABLE, { id: 'b1', label: 'x', at: new Date(BOUNDARY) }, { bypassTenantAudit: true }); const day = async (from: string, to: string) => - (await driver.find(TABLE, { where: { at: { $gte: from, $lt: to } } })).map((r: any) => r.id); + (await driver.find(TABLE, { object: TABLE, where: { at: { $gte: from, $lt: to } } })).map((r: any) => r.id); // 20:00Z belongs to 2026-03-20 in UTC. Read against the server's local // midnight (Asia/Shanghai) it would fall on the 21st instead — which is @@ -115,7 +115,7 @@ describe.skipIf(!URL)('Field.datetime on Postgres is timezone-independent (#3912 it('presents the stored instant as canonical UTC on read', async () => { await driver.create(TABLE, { id: 'r', label: 'r', at: '2026-03-20 12:00:00' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(TABLE, 'r', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'r' } }, { bypassTenantAudit: true }); expect(new Date(row.at).toISOString()).toBe(MIDDAY); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts b/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts index 643ec0477e..7ff55c2eda 100644 --- a/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts @@ -49,12 +49,12 @@ async function seedRemote(path: string) { (fixture as any).name = 'fixture'; await fixture.connect?.(); // Physical table name deliberately differs from the object name used later. - await (fixture as any).initObjects([{ name: 'remote_customers', fields: FIELDS }]); - await (fixture as any).create('remote_customers', { + await (fixture).initObjects([{ name: 'remote_customers', fields: FIELDS }]); + await (fixture).create('remote_customers', { id: 'c1', name: 'Acme', flag: true, meta: { tier: 'gold' }, when: '2026-01-01', amount: 100, seen_at: new Date('2026-01-02T10:00:00.000Z'), }); - await (fixture as any).create('remote_customers', { + await (fixture).create('remote_customers', { id: 'c2', name: 'Globex', flag: false, meta: { tier: 'silver' }, when: '2026-02-15', amount: 250, seen_at: new Date('2026-02-16T08:00:00.000Z'), }); @@ -86,7 +86,7 @@ describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () ).not.toThrow(); // The bug: this used to throw `no such table: ext_customer`. - const rows = await ext.find('ext_customer', {} as any); + const rows = await ext.find('ext_customer', { object: 'ext_customer' }); expect(rows).toHaveLength(2); const acme = rows.find((r: any) => r.name === 'Acme'); @@ -98,24 +98,24 @@ describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () expect(typeof acme.amount).toBe('number'); // numeric scalar // count + findOne route to the remote table too. - expect(await ext.count('ext_customer', {} as any)).toBe(2); - const one = await ext.findOne('ext_customer', { where: { name: 'Globex' } } as any); + expect(await ext.count('ext_customer', { object: 'ext_customer' })).toBe(2); + const one = await ext.findOne('ext_customer', { object: 'ext_customer', where: { name: 'Globex' } }); expect(one?.name).toBe('Globex'); // Filtered reads hit the remote table. - const filtered = await ext.find('ext_customer', { where: { name: 'Acme' } } as any); + const filtered = await ext.find('ext_customer', { object: 'ext_customer', where: { name: 'Acme' } }); expect(filtered).toHaveLength(1); expect(filtered[0].name).toBe('Acme'); // Date filter — guards the coercion re-keying (§3): coercion maps are keyed // by the OBJECT name even though the builder now targets the remote table. - const byDate = await ext.find('ext_customer', { where: { when: '2026-02-15' } } as any); + const byDate = await ext.find('ext_customer', { object: 'ext_customer', where: { when: '2026-02-15' } }); expect(byDate).toHaveLength(1); expect(byDate[0].name).toBe('Globex'); // Datetime filter — the SQLite epoch-affinity case the §3 trap would break // if coercion were keyed by the physical (remote) name instead of object. - const byDatetime = await ext.find('ext_customer', { where: { seen_at: '2026-01-02T10:00:00.000Z' } } as any); + const byDatetime = await ext.find('ext_customer', { object: 'ext_customer', where: { seen_at: '2026-01-02T10:00:00.000Z' } }); expect(byDatetime.map((r: any) => r.name)).toContain('Acme'); // No object-named table was ever created in the remote db (no DDL leaked). @@ -132,7 +132,7 @@ describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () await ext.connect?.(); try { await expect( - ext.initObjects([{ name: 'ext_customer', fields: FIELDS as any }]), + ext.initObjects([{ name: 'ext_customer', fields: FIELDS }]), ).rejects.toBeInstanceOf(ExternalSchemaModeViolationError); } finally { await ext.disconnect?.(); @@ -144,7 +144,7 @@ describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () await ext.connect?.(); try { ext.registerExternalObject!({ name: 'remote_customers', external: {}, fields: FIELDS as any }); - const rows = await ext.find('remote_customers', {} as any); + const rows = await ext.find('remote_customers', { object: 'remote_customers' }); expect(rows.length).toBe(2); } finally { await ext.disconnect?.(); @@ -156,7 +156,7 @@ describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () await ext.connect?.(); try { ext.registerExternalObject!({ name: 'ext_cust2', external: { remoteName: 'remote_customers', remoteSchema: 'mart' }, fields: FIELDS as any }); - const rows = await ext.find('ext_cust2', {} as any); + const rows = await ext.find('ext_cust2', { object: 'ext_cust2' }); expect(rows.length).toBe(2); } finally { await ext.disconnect?.(); diff --git a/packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts b/packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts index aae994e8a8..27aae14e04 100644 --- a/packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SqlDriver } from '../src/index.js'; +import type { FilterCondition } from '@objectstack/spec/data'; describe('SqlDriver rejects an uncompilable filter instead of dropping it', () => { let driver: SqlDriver; @@ -48,8 +49,12 @@ describe('SqlDriver rejects an uncompilable filter instead of dropping it', () = await driver.create('deal', { id: '2', stage: 'lost', amount: 20 }); }); + // The cast is the point of the file: every `where` below is a shape + // `isFilterAST()` REFUSED, fed in deliberately to prove the driver throws + // rather than dropping it. `unknown` is the honest parameter type; the cast + // is what lets an off-spec value reach a parameter that forbids it. const find = (where: unknown) => - driver.find('deal', { object: 'deal', fields: ['id'], where } as any); + driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition }); it('throws on a bare triple whose operator the AST gate refused', async () => { // The exact shape a stored single-condition `before` view produced. diff --git a/packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts b/packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts index 6e323e95f9..e1587ea931 100644 --- a/packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts @@ -263,7 +263,7 @@ describe('SqlDriver index drift (#3728)', () => { expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); // Existing rows survived, and the cross-tenant insert the issue is about works. - expect(await driver.count('product', {} as any)).toBe(2); + expect(await driver.count('product', { object: 'product' })).toBe(2); const b = await driver.create('product', { organization_id: 'org_b', code: 'PROD-00001' }); expect(b.code).toBe('PROD-00001'); diff --git a/packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts b/packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts index f684d7c133..39139d6048 100644 --- a/packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts @@ -35,17 +35,17 @@ describe('SqlDriver — contains escapes LIKE metacharacters (P0-3)', () => { }); it('a "%" value matches only rows containing a literal %, not every row', async () => { - const r = await driver.find('docs', { where: { title: { $contains: '%' } } }); + const r = await driver.find('docs', { object: 'docs', where: { title: { $contains: '%' } } }); expect(r.map((x: any) => x.id)).toEqual(['1']); }); it('a "_" value matches only rows containing a literal _, not any single char', async () => { - const r = await driver.find('docs', { where: { title: { $contains: '_' } } }); + const r = await driver.find('docs', { object: 'docs', where: { title: { $contains: '_' } } }); expect(r.map((x: any) => x.id)).toEqual(['3']); }); it('an ordinary substring still matches normally', async () => { - const r = await driver.find('docs', { where: { title: { $contains: 'sale' } } }); + const r = await driver.find('docs', { object: 'docs', where: { title: { $contains: 'sale' } } }); expect(r.map((x: any) => x.id)).toEqual(['1']); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts b/packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts index 368a57e8c7..bf2e708542 100644 --- a/packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts @@ -41,8 +41,8 @@ describe('SqlDriver multi-write transaction (deadlock regression)', () => { it('commits TWO writes in one transaction without hanging', async () => { const k = await setup(); const trx = await driver!.beginTransaction(); - await withTimeout(driver!.create('t', { id: '1', name: 'A' }, { transaction: trx } as any), 6000, 'create #1'); - await withTimeout(driver!.create('t', { id: '2', name: 'B' }, { transaction: trx } as any), 6000, 'create #2'); + await withTimeout(driver!.create('t', { id: '1', name: 'A' }, { transaction: trx }), 6000, 'create #1'); + await withTimeout(driver!.create('t', { id: '2', name: 'B' }, { transaction: trx }), 6000, 'create #2'); await withTimeout(driver!.commit(trx), 6000, 'commit'); const rows = await k('t').select(); expect(rows.map((r: any) => r.id).sort()).toEqual(['1', '2']); @@ -51,8 +51,8 @@ describe('SqlDriver multi-write transaction (deadlock regression)', () => { it('rolls back all writes when the transaction is aborted', async () => { const k = await setup(); const trx = await driver!.beginTransaction(); - await driver!.create('t', { id: '1', name: 'A' }, { transaction: trx } as any); - await driver!.create('t', { id: '2', name: 'B' }, { transaction: trx } as any); + await driver!.create('t', { id: '1', name: 'A' }, { transaction: trx }); + await driver!.create('t', { id: '2', name: 'B' }, { transaction: trx }); await driver!.rollback(trx); const rows = await k('t').select(); expect(rows.length).toBe(0); diff --git a/packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts b/packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts index eb1f27d606..83850cfd1a 100644 --- a/packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts @@ -68,7 +68,7 @@ describe('SqlDriver — field key alongside a nested $and (#3650)', () => { }); it('intersects every level on find()', async () => { - const rows = await driver.find('opportunity', { where: NESTED_AND } as any); + const rows = await driver.find('opportunity', { object: 'opportunity', where: NESTED_AND }); expect(rows.map((r: any) => r.id)).toEqual(['b']); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts b/packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts index 938eee2976..0386994792 100644 --- a/packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts @@ -42,60 +42,60 @@ describe('SqlDriver — null / empty operators (#2704)', () => { describe('array-format where', () => { it('equals + null → IS NULL (baseline that already worked)', async () => { - const rows = await driver.find('tasks', { where: [['assignee', '=', null]] } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: [['assignee', '=', null]] }); expect(ids(rows)).toEqual(['2', '4']); }); it.each(['is_null', 'isnull', 'is_empty'])('%s → IS NULL', async (op) => { - const rows = await driver.find('tasks', { where: [['assignee', op, true]] } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: [['assignee', op, true]] }); expect(ids(rows)).toEqual(['2', '4']); }); it.each(['is_not_null', 'isnotnull', 'is_not_empty'])('%s → IS NOT NULL', async (op) => { - const rows = await driver.find('tasks', { where: [['assignee', op, true]] } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: [['assignee', op, true]] }); expect(ids(rows)).toEqual(['1', '3']); }); it('!= null → IS NOT NULL (not a `<> NULL` that matches nothing)', async () => { - const rows = await driver.find('tasks', { where: [['assignee', '!=', null]] } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: [['assignee', '!=', null]] }); expect(ids(rows)).toEqual(['1', '3']); }); it('unknown operator throws instead of returning the whole table', async () => { await expect( - driver.find('tasks', { where: [['assignee', 'totally_bogus', null]] } as any), + driver.find('tasks', { object: 'tasks', where: [['assignee', 'totally_bogus', null]] }), ).rejects.toThrow(/Unsupported filter operator/); }); it('count with is_null is scoped, not the whole table', async () => { - const count = await driver.count('tasks', { where: [['assignee', 'isnull', true]] } as any); + const count = await driver.count('tasks', { object: 'tasks', where: [['assignee', 'isnull', true]] }); expect(count).toBe(2); }); }); describe('object-format where ($-operators)', () => { it('$null: true → IS NULL', async () => { - const rows = await driver.find('tasks', { where: { assignee: { $null: true } } } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: { assignee: { $null: true } } }); expect(ids(rows)).toEqual(['2', '4']); }); it('$null: false → IS NOT NULL', async () => { - const rows = await driver.find('tasks', { where: { assignee: { $null: false } } } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: { assignee: { $null: false } } }); expect(ids(rows)).toEqual(['1', '3']); }); it('$ne: null → IS NOT NULL', async () => { - const rows = await driver.find('tasks', { where: { assignee: { $ne: null } } } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: { assignee: { $ne: null } } }); expect(ids(rows)).toEqual(['1', '3']); }); it('$startsWith → prefix LIKE', async () => { - const rows = await driver.find('tasks', { where: { assignee: { $startsWith: 'a' } } } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: { assignee: { $startsWith: 'a' } } }); expect(ids(rows)).toEqual(['1']); }); it('$regex (better-auth contains) → substring LIKE, not exact match', async () => { - const rows = await driver.find('tasks', { where: { assignee: { $regex: 'aro' } } } as any); + const rows = await driver.find('tasks', { object: 'tasks', where: { assignee: { $regex: 'aro' } } }); expect(ids(rows)).toEqual(['3']); }); @@ -112,7 +112,7 @@ describe('SqlDriver — null / empty operators (#2704)', () => { it('unknown $-operator throws instead of a silent equality compare', async () => { await expect( - driver.find('tasks', { where: { assignee: { $bogus: 1 } } } as any), + driver.find('tasks', { object: 'tasks', where: { assignee: { $bogus: 1 } } }), ).rejects.toThrow(/Unsupported filter operator/); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts b/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts index f062807260..2c9b9fc70e 100644 --- a/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts @@ -75,7 +75,7 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true }); + const row = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z1' } }, { bypassTenantAudit: true }); // control expect(row.f_number).toBe(42); @@ -95,8 +95,8 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => await driver.create('zoo', { id: 'z2', name: 'B', f_boolean: true, f_toggle: true }, { bypassTenantAudit: true }); await driver.create('zoo', { id: 'z3', name: 'C', f_boolean: false, f_toggle: false }, { bypassTenantAudit: true }); - const on = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true }); - const off = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true }); + const on = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z2' } }, { bypassTenantAudit: true }); + const off = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z3' } }, { bypassTenantAudit: true }); // control expect(on.f_boolean).toBe(true); @@ -121,7 +121,7 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('zoo', 'z4', { bypassTenantAudit: true }); + const row = await driver.findOne('zoo', { object: 'zoo', where: { id: 'z4' } }, { bypassTenantAudit: true }); expect(row.f_record).toEqual({ home: '+1', work: '+2' }); expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 }); @@ -187,7 +187,7 @@ describe('SqlDriver numeric read coercion repairs legacy TEXT columns', () => { { id: 'L1', name: 'old-row', f_rating: 4, f_slider: 25, f_progress: 60 }, { bypassTenantAudit: true }, ); - const row = await driver.findOne('legacy', 'L1', { bypassTenantAudit: true }); + const row = await driver.findOne('legacy', { object: 'legacy', where: { id: 'L1' } }, { bypassTenantAudit: true }); expect(typeof row.f_rating).toBe('number'); expect(row.f_rating).toBe(4); @@ -202,7 +202,7 @@ describe('SqlDriver numeric read coercion repairs legacy TEXT columns', () => { // Hand-write a row with a null and a non-numeric string straight to the // TEXT columns, bypassing the driver, to model messy legacy data. await knex('legacy').insert({ id: 'L2', name: 'messy', f_rating: null, f_slider: 'n/a', f_progress: '60' }); - const row = await driver.findOne('legacy', 'L2', { bypassTenantAudit: true }); + const row = await driver.findOne('legacy', { object: 'legacy', where: { id: 'L2' } }, { bypassTenantAudit: true }); expect(row.f_rating).toBeNull(); // null stays null, not 0 expect(row.f_slider).toBe('n/a'); // non-numeric junk is preserved, not NaN diff --git a/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts b/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts index 8897c259d1..2cab2940ba 100644 --- a/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts @@ -56,7 +56,7 @@ describe('SqlDriver filter logic conformance (SQLite)', () => { describe('shared conformance cases', () => { for (const c of FILTER_LOGIC_CASES) { it(c.name, async () => { - const rows = await driver.find('t', { where: c.filter }); + const rows = await driver.find('t', { object: 't', where: c.filter }); const got = rows .map((r: any) => String(r.id)) .sort((x: string, y: string) => x.localeCompare(y)); @@ -91,6 +91,7 @@ describe('SqlDriver filter logic conformance (SQLite)', () => { it('matches only the rows inside the abutting windows', async () => { const rows = await driver.find('task', { + object: 'task', where: { $or: [ { end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } }, @@ -103,6 +104,7 @@ describe('SqlDriver filter logic conformance (SQLite)', () => { it('keeps a window AND-ed with a sibling key in the same branch', async () => { const rows = await driver.find('task', { + object: 'task', where: { $or: [{ id: 'nope' }, { end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' }] }, }); expect(rows.map((r: any) => r.id)).toEqual(['d15']); diff --git a/packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts b/packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts index e37c987d9a..2b707978d5 100644 --- a/packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts @@ -38,6 +38,7 @@ import { PAGINATION_ROWS, PAGINATION_UNORDERED_CASES, } from '@objectstack/spec/data'; +import type { QueryAST } from '@objectstack/spec/data'; import { SqlDriver } from '../src/index.js'; /** @@ -99,8 +100,8 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { const page = await driver.find( 'ticket', - { orderBy: [...testCase.orderBy], limit: testCase.pageSize, offset } as any, - { bypassTenantAudit: true } as any, + { object: 'ticket', orderBy: [...testCase.orderBy], limit: testCase.pageSize, offset }, + { bypassTenantAudit: true }, ); seen.push(...page.map((r) => String(r.id))); } @@ -115,8 +116,8 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { const page = await driver.find( 'ticket', - { orderBy: [...testCase.orderBy], limit: testCase.pageSize, offset } as any, - { bypassTenantAudit: true } as any, + { object: 'ticket', orderBy: [...testCase.orderBy], limit: testCase.pageSize, offset }, + { bypassTenantAudit: true }, ); paged.push(...page); } @@ -126,8 +127,8 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# // user-facing half of the guarantee. const whole = await driver.find( 'ticket', - { orderBy: [...testCase.orderBy] } as any, - { bypassTenantAudit: true } as any, + { object: 'ticket', orderBy: [...testCase.orderBy] }, + { bypassTenantAudit: true }, ); expect(paged.map((r) => r.id)).toEqual(whole.map((r) => r.id)); }); @@ -139,8 +140,8 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { const page = await driver.find( 'ticket', - { limit: testCase.pageSize, offset } as any, - { bypassTenantAudit: true } as any, + { object: 'ticket', limit: testCase.pageSize, offset }, + { bypassTenantAudit: true }, ); seen.push(...page.map((r) => String(r.id))); } @@ -155,8 +156,8 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { const page = await driver.find( 'ticket', - { limit: testCase.pageSize, offset } as any, - { bypassTenantAudit: true } as any, + { object: 'ticket', limit: testCase.pageSize, offset }, + { bypassTenantAudit: true }, ); paged.push(...page.map((r) => String(r.id))); } @@ -174,7 +175,7 @@ describe('SqlDriver — paged reads are a partition of the result set (objectui# // ORDER BY would only change plan selection. The order below is SQLite's // own answer, not one this driver asked for — which the shuffled fixture // ids make visible. - const rows = await driver.find('ticket', {} as any, { bypassTenantAudit: true } as any); + const rows = await driver.find('ticket', { object: 'ticket' }, { bypassTenantAudit: true }); expect(rows.map((r) => r.id)).toEqual(PAGINATION_ROWS.map((r) => r.id)); }); }); @@ -198,8 +199,8 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { await driver.disconnect(); }); - const sqlOfFind = (query: Record) => - driver.sqlOf(() => driver.find('ticket', query as any, { bypassTenantAudit: true } as any)); + const sqlOfFind = (query: Omit) => + driver.sqlOf(() => driver.find('ticket', { object: 'ticket', ...query }, { bypassTenantAudit: true })); it('appends `id` after a non-unique sort key', async () => { const sql = await sqlOfFind({ orderBy: [{ field: 'status', order: 'asc' }] }); @@ -250,9 +251,9 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { // unordered and 7.8 ms with `ORDER BY id`. `MongoDBDriver.findOne` has // never sorted either — this keeps the two drivers saying the same thing. const sql = await driver.sqlOf(() => - driver.findOne('ticket', { where: { status: 'open' } } as any, { + driver.findOne('ticket', { object: 'ticket', where: { status: 'open' } }, { bypassTenantAudit: true, - } as any), + }), ); expect(sql).not.toMatch(/order by/i); expect(sql).toMatch(/limit/i); @@ -260,9 +261,9 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { it('still honors an orderBy the caller gave findOne, tie-breaker and all', async () => { const sql = await driver.sqlOf(() => - driver.findOne('ticket', { orderBy: [{ field: 'status', order: 'desc' }] } as any, { + driver.findOne('ticket', { object: 'ticket', orderBy: [{ field: 'status', order: 'desc' }] }, { bypassTenantAudit: true, - } as any), + }), ); expect(sql).toMatch(/order by .*`status` desc, .*`id` desc/i); }); @@ -275,7 +276,7 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { // more on the unsorted paged read: there is no requested sort to fall back // to, so a wrong guess turns a reshuffle into a failed read. expect(driver['paginationTieBreaker']('some_remote_table')).toBeNull(); - expect(driver['orderKeysFor']('some_remote_table', { limit: 5, offset: 5 } as any)).toEqual([]); + expect(driver['orderKeysFor']('some_remote_table', { object: 'some_remote_table', limit: 5, offset: 5 })).toEqual([]); }); it('says so, once, when it cannot keep the guarantee on a table it did not create', () => { @@ -285,8 +286,8 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { // rule exists at all. const warn = vi.spyOn(driver['logger'], 'warn').mockImplementation(() => {}); try { - driver['orderKeysFor']('some_remote_table', { limit: 5, offset: 5 } as any); - driver['orderKeysFor']('some_remote_table', { limit: 5, offset: 10 } as any); + driver['orderKeysFor']('some_remote_table', { object: 'some_remote_table', limit: 5, offset: 5 }); + driver['orderKeysFor']('some_remote_table', { object: 'some_remote_table', limit: 5, offset: 10 }); expect(warn, 'once per object, not per query').toHaveBeenCalledTimes(1); const message = warn.mock.calls[0]![0]; expect(message, 'names the object').toContain('some_remote_table'); @@ -294,12 +295,12 @@ describe('SqlDriver — the ORDER BY that reaches the database', () => { expect(message, 'names a remedy').toMatch(/orderBy/); // A managed table keeps the guarantee, so it must stay quiet. - driver['orderKeysFor']('ticket', { limit: 5, offset: 5 } as any); + driver['orderKeysFor']('ticket', { object: 'ticket', limit: 5, offset: 5 }); // So must an unpaged read, and a sorted one: neither is the silent case. - driver['orderKeysFor']('some_remote_table', {} as any); + driver['orderKeysFor']('some_remote_table', { object: 'some_remote_table' }); driver['orderKeysFor']( 'some_remote_table', - { orderBy: [{ field: 'status', order: 'asc' }], limit: 5 } as any, + { object: 'some_remote_table', orderBy: [{ field: 'status', order: 'asc' }], limit: 5 }, ); expect(warn).toHaveBeenCalledTimes(1); } finally { diff --git a/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts b/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts index 0c43eae8d0..958594eba1 100644 --- a/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts @@ -59,24 +59,24 @@ describe('SqlDriver retention prune on a builtin created_at timestamp column', ( const deleted = await driver.deleteMany( 'retention_probe', - { where: { created_at: { $lt: cutoffIso } } }, + { object: 'retention_probe', where: { created_at: { $lt: cutoffIso } } }, { bypassTenantAudit: true }, ); expect(deleted).toBe(2); - const remaining = await driver.find('retention_probe', {}); + const remaining = await driver.find('retention_probe', { object: 'retention_probe' }); expect(remaining.map((r: any) => r.id)).toEqual(['new1']); }); it('an ISO-8601 cutoff before every row deletes nothing', async () => { const deleted = await driver.deleteMany( 'retention_probe', - { where: { created_at: { $lt: '2020-01-01T00:00:00.000Z' } } }, + { object: 'retention_probe', where: { created_at: { $lt: '2020-01-01T00:00:00.000Z' } } }, { bypassTenantAudit: true }, ); expect(deleted).toBe(0); - const remaining = await driver.find('retention_probe', {}); + const remaining = await driver.find('retention_probe', { object: 'retention_probe' }); expect(remaining.map((r: any) => r.id).sort()).toEqual(['new1', 'old1', 'old2']); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts b/packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts index bbdccfae59..bce0ce5339 100644 --- a/packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts @@ -44,7 +44,7 @@ describe('SqlDriver.getSchemaSyncStats', () => { it('counts every table it creates in an empty store', async () => { const driver = sqlite(); try { - await driver.initObjects(OBJECTS as any); + await driver.initObjects(OBJECTS); expect(driver.getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); } finally { @@ -56,9 +56,9 @@ describe('SqlDriver.getSchemaSyncStats', () => { it('stays "created from empty" across repeated syncs of its own tables', async () => { const driver = sqlite(); try { - await driver.initObjects(OBJECTS as any); - await driver.initObjects(OBJECTS as any); - await driver.syncSchema('alpha', OBJECTS[0] as any); + await driver.initObjects(OBJECTS); + await driver.initObjects(OBJECTS); + await driver.syncSchema('alpha', OBJECTS[0]); expect(driver.getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); } finally { @@ -84,7 +84,7 @@ describe('SqlDriver.getSchemaSyncStats', () => { // long after the run itself has passed. const first = new SqlDriver(opts); try { - await first.initObjects(OBJECTS as any); + await first.initObjects(OBJECTS); expect(first.getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); } finally { await first.disconnect(); @@ -93,7 +93,7 @@ describe('SqlDriver.getSchemaSyncStats', () => { // A later boot against the same store — the tables precede it. const second = new SqlDriver(opts); try { - await second.initObjects(OBJECTS as any); + await second.initObjects(OBJECTS); expect(second.getSchemaSyncStats()).toEqual({ created: 0, existing: 2 }); } finally { @@ -104,8 +104,8 @@ describe('SqlDriver.getSchemaSyncStats', () => { it('a store that gains an object later is still not one we found', async () => { const driver = sqlite(); try { - await driver.initObjects([OBJECTS[0]] as any); - await driver.syncSchema('gamma', { name: 'gamma', fields: { n: { type: 'string' } } } as any); + await driver.initObjects([OBJECTS[0]]); + await driver.syncSchema('gamma', { name: 'gamma', fields: { n: { type: 'string' } } }); expect(driver.getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); } finally { diff --git a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts index 346d7fe3b1..a7f133c8a6 100644 --- a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts @@ -131,7 +131,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { users: ['u1', 'u2'], }); - const results = await driver.find('multi_test', {}); + const results = await driver.find('multi_test', { object: 'multi_test' }); const row = results[0]; expect(row.tags).toEqual(['a', 'b']); @@ -154,7 +154,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { expect(columns).toHaveProperty('completion'); await driver.create('percent_test', { completion: 0.85 }); - const res = await driver.find('percent_test', {}); + const res = await driver.find('percent_test', { object: 'percent_test' }); expect(res[0].completion).toBe(0.85); }); @@ -183,7 +183,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { owner: 'u-1', watchers: ['u-2', 'u-3'], }); - const res = await driver.find('ticket_user_test', {}); + const res = await driver.find('ticket_user_test', { object: 'ticket_user_test' }); expect(res[0].owner).toBe('u-1'); expect(res[0].watchers).toEqual(['u-2', 'u-3']); }); @@ -276,7 +276,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { work_hours: '09:00:00', }); - const res = await driver.find('new_types_test', {}); + const res = await driver.find('new_types_test', { object: 'new_types_test' }); const row = res[0]; expect(row.email).toBe('test@example.com'); @@ -341,7 +341,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { }, ]; - await driver.initObjects(objects as any); + await driver.initObjects(objects); // PRAGMA proves a non-PK unique index was actually created. const list: any = await knexInstance.raw('PRAGMA index_list(idx_unique_obj)'); @@ -368,7 +368,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { }, ]; - await driver.initObjects(objects as any); + await driver.initObjects(objects); const list: any = await knexInstance.raw('PRAGMA index_list(idx_multi_obj)'); const declared = list.filter((i: any) => !String(i.name).startsWith('sqlite_autoindex')); @@ -382,7 +382,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { ).rejects.toThrow(/UNIQUE constraint failed|duplicate key value/); // Differing channel → allowed. await driver.create('idx_multi_obj', { notification_id: 'n1', recipient_id: 'r1', channel: 'email' }); - const rows = await driver.find('idx_multi_obj', {}); + const rows = await driver.find('idx_multi_obj', { object: 'idx_multi_obj' }); expect(rows.length).toBe(2); }); @@ -397,12 +397,12 @@ describe('SqlDriver Schema Sync (SQLite)', () => { }, ]; - await driver.initObjects(objects as any); + await driver.initObjects(objects); // Two rows with no dedup_key (NULL) must both be insertable. await driver.create('idx_null_obj', {}); await driver.create('idx_null_obj', {}); - const rows = await driver.find('idx_null_obj', {}); + const rows = await driver.find('idx_null_obj', { object: 'idx_null_obj' }); expect(rows.length).toBe(2); }); @@ -418,7 +418,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { }, ]; - await driver.initObjects(objects as any); + await driver.initObjects(objects); const list: any = await knexInstance.raw('PRAGMA index_list(idx_plain_obj)'); const declared = list.filter((i: any) => !String(i.name).startsWith('sqlite_autoindex')); @@ -435,9 +435,9 @@ describe('SqlDriver Schema Sync (SQLite)', () => { }, ]; - await driver.initObjects(objects as any); + await driver.initObjects(objects); // Second run must not throw "index already exists". - await driver.initObjects(objects as any); + await driver.initObjects(objects); const list: any = await knexInstance.raw('PRAGMA index_list(idx_idem_obj)'); const declared = list.filter((i: any) => !String(i.name).startsWith('sqlite_autoindex')); @@ -460,7 +460,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { ]; // Must not throw even though `total` has no physical column. - await driver.initObjects(objects as any); + await driver.initObjects(objects); const list: any = await knexInstance.raw('PRAGMA index_list(idx_virtual_obj)'); const declared = list.filter((i: any) => !String(i.name).startsWith('sqlite_autoindex')); diff --git a/packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts b/packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts index 84dc093eee..6bb01bbf47 100644 --- a/packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts @@ -44,7 +44,7 @@ describe('SqlDriver Server-Timing db span', () => { it('records a db mark with a query count for a real find()', async () => { const t = new PerfTiming(); const rows = await runWithPerfTiming(t, () => - driver.find('orders', { where: { status: 'open' } }), + driver.find('orders', { object: 'orders', where: { status: 'open' } }), ); expect(rows).toHaveLength(1); const mark = dbMark(t); diff --git a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts index 292cafc8c0..c54c2e9239 100644 --- a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts @@ -92,14 +92,14 @@ describe('sql-driver — temporal conformance', () => { for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); if (c.tokenFilter) { it(`${c.name} — via relative tokens`, async () => { - const rows = await driver.find('conformance', { where: resolveTokens(c.tokenFilter) } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: resolveTokens(c.tokenFilter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -143,7 +143,7 @@ describe('sql-driver — temporal conformance on un-backfilled legacy storage', // already swept above — a divergence here is a repair-path bug by construction. for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -183,7 +183,7 @@ describe('sql-driver — Field.time conformance', () => { for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -227,7 +227,7 @@ describe('sql-driver — Field.time conformance on un-backfilled legacy storage' for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts b/packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts index 2c0ff526ab..5b407844b6 100644 --- a/packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts @@ -81,6 +81,7 @@ describe('Field.time canonical writes (#3994)', () => { } const hits = await driver.find('shift', { + object: 'shift', where: { starts_at: { $gte: '09:00:00', $lte: '18:00:00' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -95,11 +96,11 @@ describe('Field.time canonical writes (#3994)', () => { await driver.create('shift', { id: 'a', label: 'a', starts_at: '14:30' }, { bypassTenantAudit: true }); await driver.create('shift', { id: 'b', label: 'b', starts_at: '14:30:00' }, { bypassTenantAudit: true }); - const hits = await driver.find('shift', { where: { starts_at: '14:30:00' } }); + const hits = await driver.find('shift', { object: 'shift', where: { starts_at: '14:30:00' } }); expect(hits.map((r: any) => r.id).sort()).toEqual(['a', 'b']); // And the comparand is canonicalised too — the minutes-only spelling // matches the same two rows. - const hits2 = await driver.find('shift', { where: { starts_at: '14:30' } }); + const hits2 = await driver.find('shift', { object: 'shift', where: { starts_at: '14:30' } }); expect(hits2.map((r: any) => r.id).sort()).toEqual(['a', 'b']); }); @@ -109,7 +110,7 @@ describe('Field.time canonical writes (#3994)', () => { for (const [id, v] of WRITE_SHAPES) { await driver.create('shift', { id, label: id, starts_at: v }, { bypassTenantAudit: true }); } - const rows = await driver.find('shift', { orderBy: [{ field: 'starts_at', order: 'asc' }] }); + const rows = await driver.find('shift', { object: 'shift', orderBy: [{ field: 'starts_at', order: 'asc' }] }); // 08:00 first; the `.500` rows after their `14:30:00` flat siblings // (`.` sorts below every digit, so lexicographic == chronological). expect((rows[0] as any).id).toBe('s_early'); @@ -161,6 +162,7 @@ describe('Field.time legacy storage: backfill and read-side repair (#3994)', () await seedLegacy(driver); const hits = await driver.find('shift', { + object: 'shift', where: { starts_at: { $gte: '09:00:00', $lte: '18:00:00' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -188,6 +190,7 @@ describe('Field.time legacy storage: backfill and read-side repair (#3994)', () // Converged storage means the plain indexable comparison now works. const hits = await driver.find('shift', { + object: 'shift', where: { starts_at: { $gte: '09:00:00', $lte: '18:00:00' } }, orderBy: [{ field: 'id', order: 'asc' }], }); diff --git a/packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts b/packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts index 8b95541003..de74903ce7 100644 --- a/packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts @@ -88,7 +88,7 @@ function suite(dialect: 'pg' | 'mysql', url: string | undefined) { await driver.create(TABLE, { id, label: id, starts_at: v }, { bypassTenantAudit: true }); } for (const [id, , presented] of WRITES) { - const row: any = await driver.findOne(TABLE, id, { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id } }, { bypassTenantAudit: true }); expect(row.starts_at, id).toBe(presented); } }); @@ -101,14 +101,14 @@ function suite(dialect: 'pg' | 'mysql', url: string | undefined) { { id: 'd', label: 'd', starts_at: new Date(Date.UTC(2026, 0, 15, 14, 30, 0, 500)) }, { bypassTenantAudit: true }, ); - const row: any = await driver.findOne(TABLE, 'd', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'd' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('14:30:00.500'); }); it('keeps milliseconds — no zero-precision rounding to the next second', async () => { // MySQL's bare TIME would ROUND '14:30:00.500' up to 14:30:01. await driver.create(TABLE, { id: 'ms', label: 'ms', starts_at: '14:30:00.500' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(TABLE, 'ms', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'ms' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('14:30:00.500'); }); @@ -119,6 +119,7 @@ function suite(dialect: 'pg' | 'mysql', url: string | undefined) { await driver.create(TABLE, { id: 'early', label: 'e', starts_at: '08:00:00' }, { bypassTenantAudit: true }); const hits = await driver.find(TABLE, { + object: TABLE, where: { starts_at: { $gte: '09:00:00', $lte: '18:00:00' } }, orderBy: [{ field: 'id', order: 'asc' }], }); @@ -127,7 +128,7 @@ function suite(dialect: 'pg' | 'mysql', url: string | undefined) { it("a NOW()-default time column records the UTC time-of-day, not the server's or session's", async () => { await driver.create(TABLE, { id: 'now', label: 'n' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne(TABLE, 'now', { bypassTenantAudit: true }); + const row: any = await driver.findOne(TABLE, { object: TABLE, where: { id: 'now' } }, { bypassTenantAudit: true }); // A leak of the +08:00 server zone is ~480 minutes; of the -04:00/-05:00 // process zone, ~240-300. Genuine clock skew is seconds. expect(minutesOffUtc(String(row.auto_at))).toBeLessThan(5); @@ -188,11 +189,11 @@ describe.skipIf(!MY_URL)('MySQL TIME → TIME(3) widening (#3994)', () => { const colType = String((list[0] as any).COLUMN_TYPE ?? (list[0] as any).column_type).toLowerCase(); expect(colType).toBe('time(3)'); - const old: any = await driver.findOne(LEGACY, 'old', { bypassTenantAudit: true }); + const old: any = await driver.findOne(LEGACY, { object: LEGACY, where: { id: 'old' } }, { bypassTenantAudit: true }); expect(old.starts_at).toBe('14:30:00'); // the wall clock must not move await driver.create(LEGACY, { id: 'ms', label: 'm', starts_at: '14:30:00.500' }, { bypassTenantAudit: true }); - const ms: any = await driver.findOne(LEGACY, 'ms', { bypassTenantAudit: true }); + const ms: any = await driver.findOne(LEGACY, { object: LEGACY, where: { id: 'ms' } }, { bypassTenantAudit: true }); expect(ms.starts_at).toBe('14:30:00.500'); // pre-widen this ROUNDED to 14:30:01 }); diff --git a/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts b/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts index 457833608b..72882e3283 100644 --- a/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts @@ -49,13 +49,13 @@ describe('Field.time canonical presentation (time-of-day, SQLite)', () => { // default (or any full timestamp that leaked into the column), bypassing the // driver write path. await raw('shift').insert({ id: 'legacy', label: 'L', starts_at: '2026-01-15 14:30:00' }); - const row: any = await driver.findOne('shift', 'legacy', { bypassTenantAudit: true }); + const row: any = await driver.findOne('shift', { object: 'shift', where: { id: 'legacy' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('14:30:00'); }); it('repairs a full-ISO value (with Z) in a time column to its time-of-day', async () => { await raw('shift').insert({ id: 'iso', label: 'I', starts_at: '2026-01-15T14:30:00.500Z' }); - const row: any = await driver.findOne('shift', 'iso', { bypassTenantAudit: true }); + const row: any = await driver.findOne('shift', { object: 'shift', where: { id: 'iso' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('14:30:00.500'); }); @@ -71,7 +71,7 @@ describe('Field.time canonical presentation (time-of-day, SQLite)', () => { ['d', '09:05:30.250', '09:05:30.250'], ] as const) { await driver.create('shift', { id, label: id, starts_at: written }, { bypassTenantAudit: true }); - const row: any = await driver.findOne('shift', id, { bypassTenantAudit: true }); + const row: any = await driver.findOne('shift', { object: 'shift', where: { id } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe(presented); } }); @@ -79,7 +79,7 @@ describe('Field.time canonical presentation (time-of-day, SQLite)', () => { it('a NOW()-default time column reads back a time-of-day, not a full timestamp', async () => { // `auto_at` omitted → the DDL default fires. await driver.create('shift', { id: 'd', label: 'D' }, { bypassTenantAudit: true }); - const row: any = await driver.findOne('shift', 'd', { bypassTenantAudit: true }); + const row: any = await driver.findOne('shift', { object: 'shift', where: { id: 'd' } }, { bypassTenantAudit: true }); expect(row.auto_at).toMatch(TIME_OF_DAY); expect(row.auto_at).not.toContain('-'); // not a `YYYY-MM-DD …` timestamp }); @@ -87,7 +87,7 @@ describe('Field.time canonical presentation (time-of-day, SQLite)', () => { it('find() (list path) normalizes time identically to findOne()', async () => { await raw('shift').insert({ id: 'l1', label: 'L1', starts_at: '2026-02-02 08:15:00' }); await driver.create('shift', { id: 'l2', label: 'L2', starts_at: '08:15:00' }, { bypassTenantAudit: true }); - const rows = await driver.find('shift', { orderBy: [{ field: 'id', order: 'asc' }] }); + const rows = await driver.find('shift', { object: 'shift', orderBy: [{ field: 'id', order: 'asc' }] }); const byId = Object.fromEntries(rows.map((r: any) => [r.id, r])); expect(byId.l1.starts_at).toBe('08:15:00'); // legacy full-timestamp repaired expect(byId.l2.starts_at).toBe('08:15:00'); // canonical write round-trips @@ -95,7 +95,7 @@ describe('Field.time canonical presentation (time-of-day, SQLite)', () => { it('leaves null untouched', async () => { await driver.create('shift', { id: 'n', label: 'N', starts_at: null }, { bypassTenantAudit: true }); - const row: any = await driver.findOne('shift', 'n', { bypassTenantAudit: true }); + const row: any = await driver.findOne('shift', { object: 'shift', where: { id: 'n' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBeNull(); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts b/packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts index e6ab2864fc..8559f4de66 100644 --- a/packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts @@ -71,7 +71,7 @@ describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { it('update({ preserveAudit }) KEEPS a caller-supplied updated_at instead of force-stamping now', async () => { await driver.create('thing', { id: 'h1', name: 'A' }, { bypassTenantAudit: true }); const historical = '2021-03-01T09:00:00.000Z'; - await driver.update('thing', 'h1', { name: 'B', updated_at: historical }, { bypassTenantAudit: true, preserveAudit: true } as any); + await driver.update('thing', 'h1', { name: 'B', updated_at: historical }, { bypassTenantAudit: true, preserveAudit: true }); const row = await raw('thing').where('id', 'h1').first(); expect(row.name).toBe('B'); expect(row.updated_at).toBe(historical); // original timeline preserved, not "now" @@ -80,7 +80,7 @@ describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { it('update({ preserveAudit }) with NO supplied updated_at still stamps now (fills-only-empty)', async () => { await driver.create('thing', { id: 'h2', name: 'A' }, { bypassTenantAudit: true }); await new Promise((r) => setTimeout(r, 5)); - await driver.update('thing', 'h2', { name: 'B' }, { bypassTenantAudit: true, preserveAudit: true } as any); + await driver.update('thing', 'h2', { name: 'B' }, { bypassTenantAudit: true, preserveAudit: true }); const row = await raw('thing').where('id', 'h2').first(); expect(row.updated_at).toMatch(ISO_Z); }); @@ -143,7 +143,7 @@ describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { // Simulate a row written by the OLD update stamp / CURRENT_TIMESTAMP default, // bypassing the driver write path entirely. await raw('thing').insert({ id: 'legacy', name: 'L', created_at: '2026-01-15 08:30:00', updated_at: '2026-01-15 08:30:00.246' }); - const row: any = await driver.findOne('thing', 'legacy', { bypassTenantAudit: true }); + const row: any = await driver.findOne('thing', { object: 'thing', where: { id: 'legacy' } }, { bypassTenantAudit: true }); expect(row.created_at).toBe('2026-01-15T08:30:00.000Z'); expect(row.updated_at).toBe('2026-01-15T08:30:00.246Z'); }); @@ -151,14 +151,14 @@ describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { it('REGRESSION (freshness probe): the repaired instant equals the UTC wall-clock, host-timezone-independent', async () => { // The zone-naive '2026-01-15 08:30:00' must mean 08:30 UTC, NOT 08:30 local. await raw('thing').insert({ id: 'fr', name: 'F', created_at: '2026-01-15 08:30:00', updated_at: '2026-01-15 08:30:00' }); - const row: any = await driver.findOne('thing', 'fr', { bypassTenantAudit: true }); + const row: any = await driver.findOne('thing', { object: 'thing', where: { id: 'fr' } }, { bypassTenantAudit: true }); expect(new Date(row.updated_at as string).getTime()).toBe(Date.parse('2026-01-15T08:30:00.000Z')); }); it('read-repair is idempotent: an already-canonical value is returned unchanged', async () => { const canonical = '2026-02-02T02:02:02.222Z'; await raw('thing').insert({ id: 'canon', name: 'C', created_at: canonical, updated_at: canonical }); - const row: any = await driver.findOne('thing', 'canon', { bypassTenantAudit: true }); + const row: any = await driver.findOne('thing', { object: 'thing', where: { id: 'canon' } }, { bypassTenantAudit: true }); expect(row.created_at).toBe(canonical); expect(row.updated_at).toBe(canonical); }); @@ -175,7 +175,7 @@ describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { try { await dd.initObjects([{ name: 'evt', fields: { created_at: { type: 'datetime' }, label: { type: 'string' } } }]); await dd.create('evt', { id: 'e1', label: 'x', created_at: new Date('2026-04-04T04:04:04.004Z') }, { bypassTenantAudit: true }); - const row: any = await dd.findOne('evt', 'e1', { bypassTenantAudit: true }); + const row: any = await dd.findOne('evt', { object: 'evt', where: { id: 'e1' } }, { bypassTenantAudit: true }); expect(typeof row.created_at).toBe('string'); expect(row.created_at).toBe('2026-04-04T04:04:04.004Z'); } finally { diff --git a/packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts index 1d423822fa..aa5e6f75b0 100644 --- a/packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts @@ -299,7 +299,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); // Existing data survived, and the cross-tenant insert now works. - expect(await driver.count('product', {} as any)).toBe(2); + expect(await driver.count('product', { object: 'product' })).toBe(2); const b = await driver.create('product', { organization_id: 'org_b', code: 'PROD-00001' }); expect(b.code).toBe('PROD-00001'); }); @@ -401,7 +401,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { expect(Object.values(uniques)).not.toContainEqual(['code']); // Data preserved, and cross-tenant reuse still works post-rebuild. - expect(await driver.count('product', {} as any)).toBe(1); + expect(await driver.count('product', { object: 'product' })).toBe(1); const b = await driver.create('product', { organization_id: 'org_b', code: 'C1', note: 'n' }); expect(b.code).toBe('C1'); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts index 0c7fe247ab..33317e2a55 100644 --- a/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts @@ -93,6 +93,6 @@ describe('SqlDriver find() recovers from unknown columns (objectstack#3821)', () }); it('propagates errors that are not about an unknown column', async () => { - await expect(driver.find('no_such_table', {} as any)).rejects.toThrow(); + await expect(driver.find('no_such_table', { object: 'no_such_table' })).rejects.toThrow(); }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts b/packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts index f717ad0ed6..040c66508d 100644 --- a/packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts @@ -102,7 +102,7 @@ describe('User NOW()-default temporal fields — canonical format (SQLite)', () expect(typeof rawRow.starts_at).toBe('string'); expect(rawRow.starts_at).toBe('2026-03-20T12:34:56.789Z'); - const row: any = await driver.findOne('event', 'e3', { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id: 'e3' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe(rawRow.starts_at); }); @@ -123,7 +123,7 @@ describe('User NOW()-default temporal fields — canonical format (SQLite)', () expect(sorted.map((r: any) => r.id)).toEqual(['explicit', 'defaulted']); for (const id of ['explicit', 'defaulted']) { - const row: any = await driver.findOne('event', id, { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id } }, { bypassTenantAudit: true }); expect(row.starts_at).toMatch(ISO_Z); expect(Number.isNaN(new Date(row.starts_at).getTime())).toBe(false); } @@ -137,14 +137,14 @@ describe('User NOW()-default temporal fields — canonical format (SQLite)', () const rawRow = await raw('event').where('id', 'legacy').first(); expect(typeof rawRow.starts_at).toBe('number'); - const row: any = await driver.findOne('event', 'legacy', { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id: 'legacy' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('2026-03-20T12:34:56.789Z'); }); it('an explicit ISO-8601-Z string is preserved (idempotent) on read', async () => { const iso = '2026-05-25T08:00:00.000Z'; await driver.create('event', { id: 'e4', label: 'D', starts_at: iso }, { bypassTenantAudit: true }); - const row: any = await driver.findOne('event', 'e4', { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id: 'e4' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe(iso); }); @@ -154,21 +154,21 @@ describe('User NOW()-default temporal fields — canonical format (SQLite)', () // A row written before this fix (or by a raw insert that took the OLD naive // `CURRENT_TIMESTAMP` default), bypassing the driver write path entirely. await raw('event').insert({ id: 'legacy', label: 'L', starts_at: '2026-01-15 08:30:00' }); - const row: any = await driver.findOne('event', 'legacy', { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id: 'legacy' } }, { bypassTenantAudit: true }); expect(row.starts_at).toBe('2026-01-15T08:30:00.000Z'); }); it('REGRESSION (host-timezone independence): the repaired instant equals the UTC wall-clock', async () => { // The zone-naive `2026-01-15 08:30:00` must mean 08:30 UTC, NOT 08:30 local. await raw('event').insert({ id: 'tz', label: 'T', starts_at: '2026-01-15 08:30:00' }); - const row: any = await driver.findOne('event', 'tz', { bypassTenantAudit: true }); + const row: any = await driver.findOne('event', { object: 'event', where: { id: 'tz' } }, { bypassTenantAudit: true }); expect(new Date(row.starts_at).getTime()).toBe(Date.parse('2026-01-15T08:30:00.000Z')); }); it('find() (list path) normalizes datetime identically to findOne(), across mixed storage', async () => { await raw('event').insert({ id: 'list1', label: 'L1', starts_at: '2026-02-02 02:02:02.200' }); await driver.create('event', { id: 'list2', label: 'L2', starts_at: new Date('2026-02-02T02:02:02.200Z') }, { bypassTenantAudit: true }); - const rows = await driver.find('event', { orderBy: [{ field: 'id', order: 'asc' }] }); + const rows = await driver.find('event', { object: 'event', orderBy: [{ field: 'id', order: 'asc' }] }); const byId = Object.fromEntries(rows.map((r: any) => [r.id, r])); expect(byId.list1.starts_at).toBe('2026-02-02T02:02:02.200Z'); expect(byId.list2.starts_at).toBe('2026-02-02T02:02:02.200Z'); @@ -179,7 +179,7 @@ describe('User NOW()-default temporal fields — canonical format (SQLite)', () try { await d2.initObjects([{ name: 'evt2', fields: { dt: { type: 'datetime' }, label: { type: 'string' } } }]); await d2.create('evt2', { id: 'n1', label: 'N', dt: null }, { bypassTenantAudit: true }); - const row: any = await d2.findOne('evt2', 'n1', { bypassTenantAudit: true }); + const row: any = await d2.findOne('evt2', { object: 'evt2', where: { id: 'n1' } }, { bypassTenantAudit: true }); expect(row.dt).toBeNull(); } finally { await d2.disconnect(); diff --git a/packages/plugins/driver-sql/src/sql-driver.test.ts b/packages/plugins/driver-sql/src/sql-driver.test.ts index 10c2cf24a3..86c61ca640 100644 --- a/packages/plugins/driver-sql/src/sql-driver.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver.test.ts @@ -40,6 +40,7 @@ describe('SqlDriver (SQLite Integration)', () => { it('should find objects with filters', async () => { const results = await driver.find('users', { + object: 'users', fields: ['name', 'age'], where: { age: { $gt: 18 } }, orderBy: [{ field: 'name', order: 'asc' }], @@ -54,6 +55,7 @@ describe('SqlDriver (SQLite Integration)', () => { // image, ...) the object may not have. The unknown column must NOT zero // the whole result — the real rows still come back, minus the phantom field. const results = await driver.find('users', { + object: 'users', fields: ['id', 'name', 'status', 'due_date', 'image'], orderBy: [{ field: 'name', order: 'asc' }], }); @@ -66,12 +68,13 @@ describe('SqlDriver (SQLite Integration)', () => { it('still surfaces non-column errors (e.g. unknown table) instead of empty', async () => { await expect( - driver.find('no_such_table', { fields: ['id'] }), + driver.find('no_such_table', { object: 'no_such_table', fields: ['id'] }), ).rejects.toThrow(); }); it('should apply simple AND/OR logic', async () => { const results = await driver.find('users', { + object: 'users', where: { $or: [{ age: 17 }, { age: { $gt: 29 } }], }, @@ -81,10 +84,10 @@ describe('SqlDriver (SQLite Integration)', () => { }); it('should find one object by id', async () => { - const [alice] = await driver.find('users', { where: { name: 'Alice' } }); + const [alice] = await driver.find('users', { object: 'users', where: { name: 'Alice' } }); expect(alice).toBeDefined(); - const fetched = await driver.findOne('users', alice.id as any); + const fetched = await driver.findOne('users', { object: 'users', where: { id: alice.id } }); expect(fetched).toBeDefined(); expect(fetched.name).toBe('Alice'); }); @@ -92,29 +95,29 @@ describe('SqlDriver (SQLite Integration)', () => { it('should create an object', async () => { await driver.create('users', { name: 'Eve', age: 22 }); - const [eve] = await driver.find('users', { where: { name: 'Eve' } }); + const [eve] = await driver.find('users', { object: 'users', where: { name: 'Eve' } }); expect(eve).toBeDefined(); expect(eve.age).toBe(22); }); it('should update an object', async () => { - const [bob] = await driver.find('users', { where: { name: 'Bob' } }); + const [bob] = await driver.find('users', { object: 'users', where: { name: 'Bob' } }); await driver.update('users', bob.id, { age: 18 }); - const updated = await driver.findOne('users', bob.id as any); + const updated = await driver.findOne('users', { object: 'users', where: { id: bob.id } }); expect(updated.age).toBe(18); }); it('should delete an object', async () => { - const [charlie] = await driver.find('users', { where: { name: 'Charlie' } }); + const [charlie] = await driver.find('users', { object: 'users', where: { name: 'Charlie' } }); await driver.delete('users', charlie.id); - const deleted = await driver.findOne('users', charlie.id as any); + const deleted = await driver.findOne('users', { object: 'users', where: { id: charlie.id } }); expect(deleted).toBeNull(); }); it('should count objects', async () => { - const count = await driver.count('users', { where: { age: 17 } } as any); + const count = await driver.count('users', { object: 'users', where: { age: 17 } }); expect(count).toBe(2); }); @@ -122,7 +125,7 @@ describe('SqlDriver (SQLite Integration)', () => { const created = await driver.create('users', { _id: 'custom-id', name: 'Frank', age: 40 }); expect(created.id).toBe('custom-id'); - const fetched = await driver.findOne('users', 'custom-id' as any); + const fetched = await driver.findOne('users', { object: 'users', where: { id: 'custom-id' } }); expect(fetched).toBeDefined(); expect(fetched.name).toBe('Frank'); }); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 5cfca205af..5f13a8a727 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -1466,21 +1466,26 @@ export class SqlDriver implements IDataDriver { return results; } + /** + * `IDataDriver.findOne` — find a single record BY QUERY. + * + * This also accepted a bare id (`findOne('task', 't1')`) through an + * undeclared `typeof query === 'string' | 'number'` branch until #4311. No + * caller outside this package's own tests used it, it was on no contract, and + * the other two drivers answer that same call differently: `MemoryDriver` + * spreads the argument (`{ ...query }` over a string yields `{0:'t',1:'1'}`) + * and `MongoDriver` reads `query.where` (undefined → an unfiltered findOne, + * i.e. an arbitrary row). One spelling meaning three things across three + * drivers is the second de-facto contract Prime Directive #12 exists to + * prevent — and the branch also bypassed the shared `findRows()` path + * (field selection, temporal coercion, unknown-column recovery, and the + * `singleRowLookup` ORDER BY decision). + * Spell an id lookup as what it is: `{ object, where: { id } }`. + */ async findOne(object: string, query: QueryAST, options?: DriverOptions): Promise { - // When called with a string/number id fall back gracefully - if (typeof query === 'string' || typeof query === 'number') { - const builder = this.getBuilder(object, options).where('id', query); - this.applyTenantScope(builder, object, options); - const res = await builder.first(); - return this.formatOutput(object, res) || null; - } - - if (query && typeof query === 'object') { - const results = await this.findRows(object, { ...query, limit: 1 }, options, true); - return results[0] || null; - } - - return null; + if (!query || typeof query !== 'object') return null; + const results = await this.findRows(object, { ...query, limit: 1 }, options, true); + return results[0] || null; } /** @@ -1818,7 +1823,7 @@ export class SqlDriver implements IDataDriver { const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName]; if (!cfgs || cfgs.length === 0) return; const parentTrx = options?.transaction as Knex.Transaction | undefined; - const timezone = (options as any)?.timezone as string | undefined; + const timezone = options?.timezone; const now = new Date(); for (const cfg of cfgs) { if (row[cfg.name] !== undefined && row[cfg.name] !== null && row[cfg.name] !== '') continue; @@ -1837,7 +1842,7 @@ export class SqlDriver implements IDataDriver { // Resolve tenant for this row: explicit field on the record wins, // then driver options, else null → global sequence. const rowTenant = cfg.tenantField ? row[cfg.tenantField] : undefined; - const optTenant = (options as any)?.tenantId; + const optTenant = options?.tenantId; const tenantId = rowTenant != null && rowTenant !== '' ? String(rowTenant) : optTenant != null && optTenant !== '' @@ -1896,7 +1901,7 @@ export class SqlDriver implements IDataDriver { * A normal update leaves the flag unset, so `updated_at` always advances. */ protected keepSuppliedUpdatedAt(formatted: Record, options?: DriverOptions): boolean { - return (options as any)?.preserveAudit === true && formatted.updated_at != null; + return options?.preserveAudit === true && formatted.updated_at != null; } async update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise { @@ -2844,7 +2849,13 @@ export class SqlDriver implements IDataDriver { if (timeCols.length) this.timeFields[key] = new Set(timeCols); } - async initObjects(objects: Array<{ name: string; fields?: Record }>): Promise { + // `tenancy` is part of what this method READS — each object flows into + // `computeAndRecordTenantField`, which consumes `obj.tenancy` to pick the + // tenant column and to set or clear the sticky explicit-opt-out. It went + // undeclared here until #4311 (`registerExternalObject` and + // `computeAndRecordTenantField` both had it), so a caller spelling the key + // correctly was rejected by the type while the driver read it regardless. + async initObjects(objects: Array<{ name: string; fields?: Record; tenancy?: any }>): Promise { // DDL gate (ADR-0015 §5.1): createTable/alterTable below mutate schema. // Also covers `syncSchema`, which delegates here. this.assertSchemaMutable('initObjects'); @@ -4365,7 +4376,7 @@ export class SqlDriver implements IDataDriver { object: string, options?: DriverOptions, ): Knex.QueryBuilder { - const tenantId = (options as any)?.tenantId; + const tenantId = options?.tenantId; if (tenantId === undefined || tenantId === null || tenantId === '') return builder; const field = this.resolveTenantField(object); if (!field) return builder; @@ -4377,7 +4388,7 @@ export class SqlDriver implements IDataDriver { // empty set falls through to the equality path: fail toward isolation, // never toward exposure. Insert-side injection (injectTenantOnInsert) // deliberately keeps `tenantId` — the active org is the write target (D5). - const rawIds = (options as any)?.tenantIds; + const rawIds = options?.tenantIds; const tenantIds = Array.isArray(rawIds) ? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '') : []; @@ -4414,7 +4425,7 @@ export class SqlDriver implements IDataDriver { row: Record, options?: DriverOptions, ): void { - const tenantId = (options as any)?.tenantId; + const tenantId = options?.tenantId; if (tenantId === undefined || tenantId === null || tenantId === '') return; const field = this.resolveTenantField(object); if (!field) return; @@ -4439,7 +4450,7 @@ export class SqlDriver implements IDataDriver { options?: DriverOptions, ): void { if (process.env.OS_TENANT_AUDIT === '0') return; - if ((options as any)?.bypassTenantAudit === true) return; + if (options?.bypassTenantAudit === true) return; // Only meaningful in multi-tenant deployments. Single-tenant stacks have no // tenant isolation, yet the kernel now ALWAYS provisions an `organization_id` // column (its existence is decoupled from the tenant flag). Column presence @@ -4447,7 +4458,7 @@ export class SqlDriver implements IDataDriver { // system/sudo write (e.g. the notification/http delivery dispatchers' claim // updates) would spam a meaningless warning on single-tenant boots. if (!this.isMultiTenantMode()) return; - const tenantId = (options as any)?.tenantId; + const tenantId = options?.tenantId; if (tenantId !== undefined && tenantId !== null && tenantId !== '') return; const field = this.resolveTenantField(object); if (!field) return; diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/plugins/driver-sqlite-wasm/package.json index 308ce7d0a0..cc69ea148e 100644 --- a/packages/plugins/driver-sqlite-wasm/package.json +++ b/packages/plugins/driver-sqlite-wasm/package.json @@ -25,7 +25,8 @@ "scripts": { "build": "tsup --config ../../../tsup.config.ts", "dev": "tsc -w", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@objectstack/core": "workspace:*", diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts index 62c9e7a251..0af0b73bb0 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts @@ -135,33 +135,33 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { expect(result).toBeDefined(); expect(result.length).toBe(3); - const count = await driver.count('orders', {}); + const count = await driver.count('orders', { object: 'orders' }); expect(count).toBe(8); }); it('should update many records', async () => { - const result = await driver.updateMany('orders', { where: { status: 'pending' } } as any, { status: 'processing' }); + const result = await driver.updateMany('orders', { object: 'orders', where: { status: 'pending' } }, { status: 'processing' }); expect(result).toBeGreaterThan(0); - const results = await driver.find('orders', { where: { status: 'processing' } }); + const results = await driver.find('orders', { object: 'orders', where: { status: 'processing' } }); expect(results.length).toBe(1); }); it('should delete many records', async () => { - const result = await driver.deleteMany('orders', { where: { status: 'cancelled' } } as any); + const result = await driver.deleteMany('orders', { object: 'orders', where: { status: 'cancelled' } }); expect(result).toBe(1); - const remaining = await driver.count('orders', {}); + const remaining = await driver.count('orders', { object: 'orders' }); expect(remaining).toBe(4); }); it('should handle empty bulk update and delete', async () => { - const result = await driver.updateMany('orders', { where: { status: 'nonexistent' } } as any, { status: 'updated' }); + const result = await driver.updateMany('orders', { object: 'orders', where: { status: 'nonexistent' } }, { status: 'updated' }); expect(result).toBe(0); - const deleteResult = await driver.deleteMany('orders', { where: { id: 'nonexistent' } } as any); + const deleteResult = await driver.deleteMany('orders', { object: 'orders', where: { id: 'nonexistent' } }); expect(deleteResult).toBe(0); }); }); @@ -186,7 +186,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); - const result = await driver.findOne('orders', 'trx1' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'trx1' } }); expect(result).toBeDefined(); expect(result.customer).toBe('TxUser'); } catch (e) { @@ -214,7 +214,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.rollbackTransaction(trx); - const result = await driver.findOne('orders', 'trx2' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'trx2' } }); expect(result).toBeNull(); } catch (e) { await driver.rollbackTransaction(trx); @@ -245,13 +245,13 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); - const created = await driver.findOne('orders', 'trx3' as any); + const created = await driver.findOne('orders', { object: 'orders', where: { id: 'trx3' } }); expect(created).toBeDefined(); - const updated = await driver.findOne('orders', '1' as any); + const updated = await driver.findOne('orders', { object: 'orders', where: { id: '1' } }); expect(updated.status).toBe('shipped'); - const deleted = await driver.findOne('orders', '5' as any); + const deleted = await driver.findOne('orders', { object: 'orders', where: { id: '5' } }); expect(deleted).toBeNull(); } catch (e) { await driver.rollbackTransaction(trx); @@ -262,12 +262,12 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { describe('Edge Cases and Error Handling', () => { it('should handle empty filters gracefully', async () => { - const results = await driver.find('orders', { where: {} }); + const results = await driver.find('orders', { object: 'orders', where: {} }); expect(results.length).toBe(5); }); it('should handle undefined query parameters', async () => { - const results = await driver.find('orders', {}); + const results = await driver.find('orders', { object: 'orders' }); expect(results.length).toBe(5); }); @@ -280,7 +280,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.create('nullable_test', { id: '1', name: null, value: null }); - const result = await driver.findOne('nullable_test', '1' as any); + const result = await driver.findOne('nullable_test', { object: 'nullable_test', where: { id: '1' } }); expect(result).toBeDefined(); expect(result.name).toBeNull(); expect(result.value).toBeNull(); @@ -288,6 +288,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { it('should handle pagination with offset and limit', async () => { const page1 = await driver.find('orders', { + object: 'orders', orderBy: [{ field: 'id', order: 'asc' }], offset: 0, limit: 2, @@ -296,6 +297,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { expect(page1[0].id).toBe('1'); const page2 = await driver.find('orders', { + object: 'orders', orderBy: [{ field: 'id', order: 'asc' }], offset: 2, limit: 2, @@ -305,12 +307,13 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { }); it('should handle offset beyond total records', async () => { - const results = await driver.find('orders', { offset: 100, limit: 10 }); + const results = await driver.find('orders', { object: 'orders', offset: 100, limit: 10 }); expect(results.length).toBe(0); }); it('should handle complex nested filters', async () => { const results = await driver.find('orders', { + object: 'orders', where: { $or: [ { $and: [{ status: 'completed' }, { amount: { $gt: 100 } }] }, @@ -324,6 +327,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { it('should handle contains filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { product: { $contains: 'top' } }, }); @@ -333,6 +337,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { it('should handle in filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { status: { $in: ['completed', 'pending'] } }, }); @@ -341,6 +346,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { it('should handle nin (not in) filter', async () => { const results = await driver.find('orders', { + object: 'orders', where: { status: { $nin: ['cancelled'] } }, }); @@ -348,14 +354,14 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { }); it('should handle findOne with query parameter', async () => { - const result = await driver.findOne('orders', { where: { customer: 'Charlie' } }); + const result = await driver.findOne('orders', { object: 'orders', where: { customer: 'Charlie' } }); expect(result).toBeDefined(); expect(result.customer).toBe('Charlie'); }); it('should return null for non-existent record', async () => { - const result = await driver.findOne('orders', 'nonexistent' as any); + const result = await driver.findOne('orders', { object: 'orders', where: { id: 'nonexistent' } }); expect(result).toBeNull(); }); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts index 5e219ce1e6..570e468442 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts @@ -178,9 +178,9 @@ describe('SqliteWasmDriver auto_number sequence', () => { ]); // No organization_id on the row, but provided via DriverOptions. - const r1 = await driver.create('contract', {}, { tenantId: 'org_options' } as any); - const r2 = await driver.create('contract', {}, { tenantId: 'org_options' } as any); - const r3 = await driver.create('contract', {}, { tenantId: 'org_other' } as any); + const r1 = await driver.create('contract', {}, { tenantId: 'org_options' }); + const r2 = await driver.create('contract', {}, { tenantId: 'org_options' }); + const r3 = await driver.create('contract', {}, { tenantId: 'org_other' }); expect(r1.contract_number).toBe('CTR-0001'); expect(r2.contract_number).toBe('CTR-0002'); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts index 9ae8c49634..d5a270d1ac 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts @@ -127,7 +127,7 @@ describe('SqliteWasmDriver Schema Sync (SQLite)', () => { users: ['u1', 'u2'], }); - const results = await driver.find('multi_test', {}); + const results = await driver.find('multi_test', { object: 'multi_test' }); const row = results[0]; expect(row.tags).toEqual(['a', 'b']); @@ -150,7 +150,7 @@ describe('SqliteWasmDriver Schema Sync (SQLite)', () => { expect(columns).toHaveProperty('completion'); await driver.create('percent_test', { completion: 0.85 }); - const res = await driver.find('percent_test', {}); + const res = await driver.find('percent_test', { object: 'percent_test' }); expect(res[0].completion).toBe(0.85); }); @@ -244,7 +244,7 @@ describe('SqliteWasmDriver Schema Sync (SQLite)', () => { work_hours: '09:00:00', }); - const res = await driver.find('new_types_test', {}); + const res = await driver.find('new_types_test', { object: 'new_types_test' }); const row = res[0]; expect(row.email).toBe('test@example.com'); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts index 3123e2ea46..4ba1ac2d17 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts @@ -40,6 +40,7 @@ describe('SqliteWasmDriver (in-memory)', () => { it('should find objects with filters', async () => { const results = await driver.find('users', { + object: 'users', fields: ['name', 'age'], where: { age: { $gt: 18 } }, orderBy: [{ field: 'name', order: 'asc' }], @@ -51,6 +52,7 @@ describe('SqliteWasmDriver (in-memory)', () => { it('should apply simple AND/OR logic', async () => { const results = await driver.find('users', { + object: 'users', where: { $or: [{ age: 17 }, { age: { $gt: 29 } }] }, }); const names = results.map((r: any) => r.name).sort(); @@ -58,34 +60,34 @@ describe('SqliteWasmDriver (in-memory)', () => { }); it('should find one object by id', async () => { - const [alice] = await driver.find('users', { where: { name: 'Alice' } }); + const [alice] = await driver.find('users', { object: 'users', where: { name: 'Alice' } }); expect(alice).toBeDefined(); - const fetched = await driver.findOne('users', alice.id as any); + const fetched = await driver.findOne('users', { object: 'users', where: { id: alice.id } }); expect(fetched.name).toBe('Alice'); }); it('should create an object', async () => { await driver.create('users', { name: 'Eve', age: 22 }); - const [eve] = await driver.find('users', { where: { name: 'Eve' } }); + const [eve] = await driver.find('users', { object: 'users', where: { name: 'Eve' } }); expect(eve.age).toBe(22); }); it('should update an object', async () => { - const [bob] = await driver.find('users', { where: { name: 'Bob' } }); + const [bob] = await driver.find('users', { object: 'users', where: { name: 'Bob' } }); await driver.update('users', bob.id, { age: 18 }); - const updated = await driver.findOne('users', bob.id as any); + const updated = await driver.findOne('users', { object: 'users', where: { id: bob.id } }); expect(updated.age).toBe(18); }); it('should delete an object', async () => { - const [charlie] = await driver.find('users', { where: { name: 'Charlie' } }); + const [charlie] = await driver.find('users', { object: 'users', where: { name: 'Charlie' } }); await driver.delete('users', charlie.id); - const deleted = await driver.findOne('users', charlie.id as any); + const deleted = await driver.findOne('users', { object: 'users', where: { id: charlie.id } }); expect(deleted).toBeNull(); }); it('should count objects', async () => { - const count = await driver.count('users', { where: { age: 17 } } as any); + const count = await driver.count('users', { object: 'users', where: { age: 17 } }); expect(count).toBe(2); }); }); @@ -114,7 +116,7 @@ describe('SqliteWasmDriver (file persistence)', () => { await d1.disconnect(); const d2 = new SqliteWasmDriver({ filename: dbPath, persist: 'on-disconnect' }); - const rows = await d2.find('items', {}); + const rows = await d2.find('items', { object: 'items' }); expect(rows.length).toBe(1); expect(rows[0].label).toBe('first'); await d2.disconnect(); @@ -132,7 +134,7 @@ describe('SqliteWasmDriver (file persistence)', () => { await d1.flush(); const d2 = new SqliteWasmDriver({ filename: dbPath, persist: 'on-write' }); - const rows = await d2.find('items', {}); + const rows = await d2.find('items', { object: 'items' }); expect(rows.length).toBe(1); await d1.disconnect(); await d2.disconnect(); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts index 148409b102..3c459e17c2 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts @@ -88,14 +88,14 @@ describe('driver-sqlite-wasm — temporal conformance', () => { for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); if (c.tokenFilter) { it(`${c.name} — via relative tokens`, async () => { - const rows = await driver.find('conformance', { where: resolveTokens(c.tokenFilter) } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: resolveTokens(c.tokenFilter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -132,7 +132,7 @@ describe('driver-sqlite-wasm — Field.time conformance', () => { for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -174,7 +174,7 @@ describe('driver-sqlite-wasm — temporal conformance on un-backfilled legacy st // already swept above — a divergence here is a repair-path bug by construction. for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -209,7 +209,7 @@ describe('driver-sqlite-wasm — Field.time conformance on un-backfilled legacy for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index cf70b02d62..fcf00e05a7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -192,6 +192,7 @@ "AggregationStage (type)", "AggregationStageSchema (const)", "AnalyticsQuery (type)", + "AnalyticsQueryInput (type)", "AnalyticsQuerySchema (const)", "ApiMethod (type)", "ApiMethodsMode (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 07e77a1d6d..c2ede14285 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3393,6 +3393,7 @@ "data/DriverDefinition:icon", "data/DriverDefinition:id", "data/DriverDefinition:label", + "data/DriverOptions:bypassTenantAudit", "data/DriverOptions:preserveAudit", "data/DriverOptions:skipCache", "data/DriverOptions:tenantId", diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 8be3b647ef..4729f904a0 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -186,3 +186,16 @@ export function defineCube(config: z.input): Cube { return CubeSchema.parse(config); } export type AnalyticsQuery = z.infer; + +/** + * Author-tier `AnalyticsQuery` — what a caller writes, before `.parse()` fills + * the defaults in. `timezone` is `.default('UTC')`, so it is optional here and + * REQUIRED on {@link AnalyticsQuery}: the two tiers are genuinely different + * types, and only the parse turns one into the other. + * + * Every executor (`IAnalyticsService.query`, the analytics strategies) takes + * the PARSED type, because a request body reaches them through the schema. Use + * this one for the literal handed to `AnalyticsQuerySchema.parse()` — the + * mirror of `QueryInput` / `QueryAST` in `data/query.zod.ts`. + */ +export type AnalyticsQueryInput = z.input; diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 5c6e2d7623..8200db066b 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -76,6 +76,26 @@ export const DriverOptionsSchema = lazySchema(() => z.object({ * usual. A normal update leaves this unset, so `updated_at` always advances. */ preserveAudit: z.boolean().optional().describe('Historical import: keep a supplied updated_at instead of force-stamping now (from ExecutionContext.preserveAudit)'), + + /** + * Silence the tenant-audit warning a driver emits when it writes to a + * tenant-scoped object without a `tenantId` — the write is deliberately + * global, not an isolation bug. + * + * This key was live long before it was declared (#4311): `SqlDriver` reads it + * (`bypassTenantAudit === true` → skip the warning) and its own warning text + * tells callers to set it, the ObjectQL engine sets it for system-context + * calls, and `service-settings` / `service-datasource` pass it on every + * global-scope write. Only the schema never heard of it, so every driver read + * went through an `as any` and every caller through an untyped options object. + * Nothing type-checked either side, so the gap stayed invisible. + * + * A driver MAY warn on a tenant-scoped write that carries neither `tenantId` + * nor this flag; it MUST NOT change what the write does. Suppressing an audit + * warning is not a permission — it does not widen, narrow, or re-target the + * rows a write touches. + */ + bypassTenantAudit: z.boolean().optional().describe('Suppress the driver tenant-audit warning for a deliberately global write on a tenant-scoped object (diagnostics only — never changes what the write touches)'), })); /** diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 80e2e958eb..4c1489971e 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -9,11 +9,32 @@ // typecheck job covered exactly four targets (spec, examples, // downstream-contract, docs code blocks) -- so for most packages NOTHING read // src/ or the tests with a type checker at all. #4311 measured the hole: -// 380 code-tier errors across 18 packages, 241 of them in driver-sql tests -// passing authored-shape literals into the freshly narrowed QueryAST. A green +// 380 code-tier errors across 18 packages, 241 of them in driver-sql. A green // test suite no tsc has ever read is not evidence of a contract; this gate // makes the coverage hole itself the failure, so it can only shrink. // +// What the first burn-down found is worth knowing before reading a number +// below as "N test literals to fix". The three drivers (driver-sql 241, +// driver-sqlite-wasm 27, driver-memory 23) were filed as one playbook -- +// author-tier literals in a parsed-tier parameter -- and 165 of their 292 +// were something else entirely: 118 for a `bypassTenantAudit` driver option +// that SqlDriver read (through an `as any`), the engine set, and two services +// passed, while `DriverOptionsSchema` had never declared it; 41 for a bare-id +// `findOne(object, id)` branch on no contract, which the other two drivers +// answered differently; 4 for a `tenancy` key `initObjects` consumed but did +// not declare; 19 for an analytics `timezone` default. The tests were right +// and the types were wrong. A tsc count is a place to look, never a verdict. +// +// The reverse also holds: a LOW count can be call sites opting out. Those same +// three packages carried 111 `as any` casts on driver-call arguments. Removing +// them left 66 fresh errors -- every one a real missing `object` the cast had +// hidden, including an `orderBy: [['id','asc']]` tuple the driver reads as +// `item.field` and therefore silently dropped, inside a helper whose whole job +// was reading rows in order. 43 of the casts were needed by nothing at all; +// exactly 2 were load-bearing (tests feeding a filter the AST gate refuses, on +// purpose). Onboarding a package is supposed to make its `typecheck` mean +// something, so the casts belong in the diff too. +// // node scripts/check-type-check-coverage.mjs // node scripts/check-type-check-coverage.mjs --self-test // @@ -87,7 +108,8 @@ const TEST_FILE = /\.(test|spec)\.tsx?$/; // implicit-any params, TS6133 unused) -- so each note says what the pile is // made of. Nobody should mistake a config-tier pile for real breakage, or -- // worse -- the reverse: `core` at 91 raw has 3 real errors, while -// `driver-sql`'s 241 are ALL real. +// `driver-sql`'s 241 were ALL real (and, as the header notes, mostly real +// about the types rather than about the tests). // // The tiers are not independent, which is why these numbers get re-measured // rather than decremented. Under `moduleResolution: NodeNext` a relative @@ -111,18 +133,6 @@ const DEBT = { errors: 12, note: 'code-tier 8 (TS2322/TS2554) + 3 config-tier + 1 noise.', }, - '@objectstack/driver-memory': { - errors: 23, - note: 'all code-tier: TS2345/TS2741/TS2339, the QueryAST authored-vs-parsed playbook.', - }, - '@objectstack/driver-sql': { - errors: 241, - note: 'ALL code-tier (TS2345 x123, TS2353 x118): tests pass authored-shape literals into the parsed QueryAST type across 8+ test files; #4196/#4286 narrowed QueryAST and no tsc ever read these tests. 63% of the whole audit; single playbook, fix as one batch.', - }, - '@objectstack/driver-sqlite-wasm': { - errors: 27, - note: 'all code-tier (TS2345/TS2353), same QueryAST playbook as driver-sql.', - }, '@objectstack/hono': { errors: 3, note: 'all code-tier (TS2769/TS18046).',