Skip to content

Commit 7ab286e

Browse files
Elon Muskclaude
andauthored
test(driver-sql): give ttl onlyWhen $null live pg + mysql legs through the existing cell testkit (#10989)
* test(driver-sql): give ttl onlyWhen $null live pg + mysql legs (#10836) The suite pinned the Reaper-shaped where `{ expires_at: { $lt: cutoff }, revoked_at: { $null: true } }` live on sqlite but COMPILE-ONLY on pg and mysql2 — hand-rolled `new SqlDriver({ client: 'pg', connection: {} })`, `.toSQL()`, string assertions. Compile-only proves the SQL text; it cannot prove the server returns those rows, and it never meets the column type each dialect actually creates (sqlite TEXT vs a real temporal column). Extends the existing file through the PG_CELL / MYSQL_CELL testkit the sibling dialect suites already use, so the compile-only assertions stay and the live legs declare themselves un-run when the URLs are absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * test(driver-sql): correct the CI-coverage note on the ttl onlyWhen live legs The card assumed CI provisions no live servers. It does: `Temporal Conformance (live PG + MySQL)` runs the whole driver-sql suite against a live postgres:16 and mysql:8.0 with OS_EXPECT_LIVE_DIALECT_MATRIX=1, so these legs execute there — on a required check, against real MySQL 8.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 047ac86 commit 7ab286e

1 file changed

Lines changed: 201 additions & 2 deletions

File tree

packages/drivers/driver-sql/src/sql-driver-ttl-onlywhen-null-dialects.test.ts

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*
99
* The Reaper (`@objectstack/objectql` LifecycleService.reap) narrows every
1010
* candidate read to `{ [ttl.field]: { $lt: cutoff }, ...onlyWhen }` — so the
11-
* exact where this suite compiles is the exact where production issues. Two
11+
* exact where this suite compiles is the exact where production issues. Three
1212
* measurements:
1313
*
1414
* 1. **Live SQLite** (better-sqlite3, in-memory): the Reaper-shaped where is
@@ -26,10 +26,57 @@
2626
* .test.ts` established). `$null: true` must render `is null`,
2727
* `$null: false` must render `is not null`, and the TTL cutoff must
2828
* remain a bound comparison on the same statement.
29+
*
30+
* 3. **[#10836] Live pg + mysql**: the same where EXECUTED against real
31+
* servers, through `PG_CELL` / `MYSQL_CELL`. Measurement 2 proves the SQL
32+
* *text*; it cannot prove the *server* returns those rows, and it never
33+
* exercises the ttl cutoff against the column type each dialect actually
34+
* creates. That is where these two dialects part company with sqlite:
35+
* `Field.datetime` stores TEXT on sqlite but a REAL temporal column here
36+
* (`timestamp with time zone` on Postgres, `datetime` on MySQL/MariaDB —
37+
* asserted below, not assumed), and an ISO-8601 `...Z` comparand against a
38+
* real temporal column under `STRICT_TRANS_TABLES` is exactly the class of
39+
* thing a string assertion cannot see.
40+
*
41+
* These are COVERAGE-EXTENSION controls, not defect controls: nothing was
42+
* broken when they were written, so there is no red pre-fix tree to point
43+
* at. What earns them is the UNFILTERED leg, which drops `onlyWhen` and
44+
* watches the tombstone get reaped on the same server, in the same run --
45+
* a live leg that stays green with the filter removed is not testing the
46+
* filter.
47+
*
48+
* Opt-in, and REPORTED when it cannot run:
49+
*
50+
* OS_TEST_POSTGRES_URL=postgres://u:p@127.0.0.1:5432/conformance \
51+
* OS_TEST_MYSQL_URL=mysql://u:p@127.0.0.1:3306/conformance \
52+
* pnpm --filter @objectstack/driver-sql test
53+
*
54+
* Without those the legs are declared un-run through `declareDialectCell`
55+
* (a NAMED skip, fatal under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`) rather than
56+
* silently omitted -- an un-run cell that reports nothing is the same defect
57+
* class as the compile-only gap this closes.
58+
*
59+
* What CI gains, stated exactly. The card that asked for these legs assumed
60+
* CI provisions no live servers, so a committed live leg would add no CI
61+
* protection. That is NOT true of this package: `Temporal Conformance
62+
* (live PG + MySQL)` runs the WHOLE driver-sql suite -- `pnpm --filter
63+
* @objectstack/driver-sql test`, not a filtered subset -- against a live
64+
* `postgres:16` and a live `mysql:8.0`, at `TZ=America/New_York`, with
65+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1` so a missing URL is a red rather than a
66+
* skip. So these legs really do execute in CI, on a REQUIRED check, against
67+
* real MySQL 8.0 rather than the MariaDB stand-in they were developed on.
68+
* They skip -- announced -- on `Test Core` and for a developer with no
69+
* servers. See the PR body for the local run that measured them.
2970
*/
3071

31-
import { describe, it, expect, afterEach } from 'vitest';
72+
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
3273
import { SqlDriver } from '../src/index.js';
74+
import {
75+
MYSQL_CELL,
76+
PG_CELL,
77+
declareDialectCell,
78+
type DialectCell,
79+
} from './live-dialect-matrix.testkit.js';
3380

3481
const CUTOFF = '2026-08-20T00:00:00.000Z';
3582
/** Exactly what LifecycleService.reap composes for
@@ -121,3 +168,155 @@ describe('ttl onlyWhen {$null} — real driver compile path, three dialects (#10
121168
});
122169
}
123170
});
171+
172+
// ─────────────────────────────────────────────────────────────────
173+
// [#10836] MEASUREMENT 3 — the same where, EXECUTED on live servers
174+
// ─────────────────────────────────────────────────────────────────
175+
176+
/** Table this file owns on the live servers. The SCHEMA it lands in is derived
177+
* per-file by the cell (#9350), so the bare name cannot collide. */
178+
const LIVE_TABLE = 'os10836_sessions';
179+
180+
/**
181+
* What `Field.datetime` really becomes on each live dialect, as knex
182+
* `columnInfo()` reports it — the fact the sqlite leg above structurally
183+
* cannot show, since sqlite has no temporal type and stores TEXT.
184+
*
185+
* Pinned as data rather than described in prose: these are the numbers the
186+
* card measured by hand (PostgreSQL 16.13, MariaDB 10.11.14), and a server
187+
* that reports something else is a FINDING to report, not a value to relax
188+
* the assertion around.
189+
*/
190+
const LIVE_TEMPORAL_TYPE: Partial<Record<DialectCell['id'], string>> = {
191+
pg: 'timestamp with time zone',
192+
mysql: 'datetime',
193+
};
194+
195+
/**
196+
* The live fixture, named as the card names it.
197+
*
198+
* The two ordinary rows sit only FOUR HOURS either side of {@link CUTOFF} on
199+
* purpose. A zone leak on these servers is ±8h (both are provisioned off UTC:
200+
* PG `Asia/Shanghai`, MySQL `+08:00`) and ±4/5h from a non-UTC process — so if
201+
* the stored instant and the ISO-Z comparand were ever folded through
202+
* different clocks, `sess_expired` and `sess_live` would swap sides of the
203+
* cutoff and this fixture would go red. A day-wide margin would absorb exactly
204+
* the defect the live legs exist to expose.
205+
*/
206+
const LIVE_ROWS = [
207+
// ordinary expired row — POSITIVE control: MUST be reaped.
208+
{ id: 'sess_expired', label: 'expired', expires_at: '2026-08-19T20:00:00.000Z' },
209+
// not yet expired — outside the cutoff either way, so it survives both sweeps
210+
// and keeps the UNFILTERED leg below from being "everything was deleted".
211+
{ id: 'sess_live', label: 'live', expires_at: '2026-08-20T04:00:00.000Z' },
212+
// tombstone: revoked, `expires_at` BACKDATED by `reconcileSessionDelete`
213+
// (#7732) so it looks MAXIMALLY expired — SPARING control: only `onlyWhen`
214+
// can save it.
215+
{
216+
id: 'sess_tombstone',
217+
label: 'tombstone',
218+
expires_at: '2026-07-01T00:00:00.000Z',
219+
revoked_at: '2026-07-01T00:00:00.000Z',
220+
},
221+
] as const;
222+
223+
const LIVE_MATRIX = 'ttl onlyWhen {$null} reap scope';
224+
225+
/** The Reaper's candidate scope with `onlyWhen` DROPPED — the counter-face. */
226+
const UNFILTERED_WHERE = { expires_at: { $lt: CUTOFF } };
227+
228+
function declareLiveSweep(cell: DialectCell): void {
229+
describe(`ttl onlyWhen {$null} on live ${cell.id} (#10836)`, () => {
230+
let driver: SqlDriver;
231+
232+
/** Ids still present, sorted — "survivors after the sweep" in the card's words. */
233+
const survivors = async (): Promise<string[]> =>
234+
(await driver.find(LIVE_TABLE, {}, { bypassTenantAudit: true }))
235+
.map((r: any) => r.id)
236+
.sort();
237+
238+
beforeEach(async () => {
239+
driver = new SqlDriver(cell.config());
240+
await driver.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {});
241+
// The REAL declaration's field types (`SysSession.expires_at` and
242+
// `.revoked_at` are both `Field.datetime`), through the driver's own
243+
// schema sync — so the columns under test are the ones production gets.
244+
await driver.initObjects([
245+
{
246+
name: LIVE_TABLE,
247+
fields: {
248+
label: { type: 'string' },
249+
expires_at: { type: 'datetime', required: true },
250+
revoked_at: { type: 'datetime', required: false },
251+
},
252+
},
253+
] as any);
254+
for (const row of LIVE_ROWS) {
255+
await driver.create(LIVE_TABLE, { ...row }, { bypassTenantAudit: true });
256+
}
257+
});
258+
259+
afterEach(async () => {
260+
await driver.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {});
261+
await driver.disconnect();
262+
});
263+
264+
it('stores the ttl field in a REAL temporal column, not the TEXT sqlite uses', async () => {
265+
const info: any = await (driver as any).knex(LIVE_TABLE).columnInfo();
266+
const expected = LIVE_TEMPORAL_TYPE[cell.id];
267+
for (const column of ['expires_at', 'revoked_at']) {
268+
expect(
269+
String(info[column]?.type),
270+
`${cell.id} reported a different type for ${column} than the card measured — that is a ` +
271+
`finding to report, not a divergence to reconcile away`,
272+
).toBe(expected);
273+
}
274+
// The fixture would be vacuous if this dialect stored text like sqlite:
275+
// the whole point of the live legs is that the cutoff meets a temporal
276+
// column here.
277+
expect(String(info.expires_at?.type)).not.toMatch(/char|text/i);
278+
});
279+
280+
it('the ISO-Z cutoff selects the expired row and the filter spares the tombstone', async () => {
281+
// The candidate read `LifecycleService.reap`'s batchedReap issues.
282+
const candidates = await driver.find(
283+
LIVE_TABLE,
284+
{ where: REAPER_WHERE },
285+
{ bypassTenantAudit: true },
286+
);
287+
expect(candidates.map((r: any) => r.id).sort()).toEqual(['sess_expired']);
288+
// Same compile funnel, second consumer.
289+
expect(await driver.count(LIVE_TABLE, { where: REAPER_WHERE }, { bypassTenantAudit: true })).toBe(1);
290+
});
291+
292+
it('the sweep deletes exactly the expired row — survivors keep the tombstone', async () => {
293+
const deleted = await driver.deleteMany(
294+
LIVE_TABLE,
295+
{ where: REAPER_WHERE },
296+
{ bypassTenantAudit: true },
297+
);
298+
expect(deleted).toBe(1);
299+
expect(await survivors()).toEqual(['sess_live', 'sess_tombstone']);
300+
});
301+
302+
it('UNFILTERED control: drop onlyWhen and the SAME sweep reaps the tombstone', async () => {
303+
// This is what earns the three legs above. Without it a live leg that
304+
// matched nothing — a mis-bound comparand, a table read in the wrong
305+
// schema — would report the identical green.
306+
const deleted = await driver.deleteMany(
307+
LIVE_TABLE,
308+
{ where: UNFILTERED_WHERE },
309+
{ bypassTenantAudit: true },
310+
);
311+
expect(deleted).toBe(2);
312+
expect(await survivors()).toEqual(['sess_live']);
313+
});
314+
});
315+
}
316+
317+
// Total, not one-way: a provisioned cell is MEASURED, an unprovisioned one is
318+
// DECLARED un-run (and fatal under OS_EXPECT_LIVE_DIALECT_MATRIX=1). There is
319+
// no third outcome available here, which is the point — a silently skipped
320+
// live leg is as green as a passing one.
321+
declareDialectCell(PG_CELL, LIVE_MATRIX, declareLiveSweep);
322+
declareDialectCell(MYSQL_CELL, LIVE_MATRIX, declareLiveSweep);

0 commit comments

Comments
 (0)