|
8 | 8 | * |
9 | 9 | * The Reaper (`@objectstack/objectql` LifecycleService.reap) narrows every |
10 | 10 | * 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 |
12 | 12 | * measurements: |
13 | 13 | * |
14 | 14 | * 1. **Live SQLite** (better-sqlite3, in-memory): the Reaper-shaped where is |
|
26 | 26 | * .test.ts` established). `$null: true` must render `is null`, |
27 | 27 | * `$null: false` must render `is not null`, and the TTL cutoff must |
28 | 28 | * 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. |
29 | 70 | */ |
30 | 71 |
|
31 | | -import { describe, it, expect, afterEach } from 'vitest'; |
| 72 | +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; |
32 | 73 | 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'; |
33 | 80 |
|
34 | 81 | const CUTOFF = '2026-08-20T00:00:00.000Z'; |
35 | 82 | /** Exactly what LifecycleService.reap composes for |
@@ -121,3 +168,155 @@ describe('ttl onlyWhen {$null} — real driver compile path, three dialects (#10 |
121 | 168 | }); |
122 | 169 | } |
123 | 170 | }); |
| 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