From b87c67d6e189d06eca9c4624e63b56d314db85f5 Mon Sep 17 00:00:00 2001 From: os-elon Date: Fri, 21 Aug 2026 03:44:38 +0000 Subject: [PATCH 1/3] feat(platform-objects): declare sys_session ttl sparing revoked tombstones Fixes #7826. sys_session declared no lifecycle at all, so nothing swept it: better-auth's only expiry-driven collector runs inside GET /get-session and can never reach a row whose cookie is never presented again. Declares class 'transient' + ttl on expires_at with a 1d window (matching sys_device_code), and onlyWhen { revoked_at: { $null: true } } so the #7732 ADR-0069 D4 audit tombstones are spared. That filter is load-bearing: the tombstone write backdates expires_at to now - 1000 and clears nothing, so an unfiltered ttl on expires_at reaps the audit records first and hardest. Tombstone retention duration remains out of scope (compliance semantics). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../sys-session-ttl-spare-tombstones.md | 24 ++ .../identity/sys-session-lifecycle.test.ts | 81 +++++++ .../src/identity/sys-session.object.ts | 31 +++ .../src/sys-session-ttl-sweep.test.ts | 227 ++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 .changeset/sys-session-ttl-spare-tombstones.md create mode 100644 packages/platform-objects/src/identity/sys-session-lifecycle.test.ts create mode 100644 packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts diff --git a/.changeset/sys-session-ttl-spare-tombstones.md b/.changeset/sys-session-ttl-spare-tombstones.md new file mode 100644 index 0000000000..5b38dad571 --- /dev/null +++ b/.changeset/sys-session-ttl-spare-tombstones.md @@ -0,0 +1,24 @@ +--- +"@objectstack/platform-objects": minor +--- + +Declare an ADR-0057 lifecycle policy on `sys_session` (#7826): the object is +now `class: 'transient'` with +`ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }`. + +**Ordinary expired sessions are now reaped** by the LifecycleService Reaper one +day after `expires_at` passes — the same window `sys_device_code` uses. Until +now nothing swept this table: better-auth's only expiry-driven collector fires +inside `GET /get-session`, so it can never reach a row whose cookie is never +presented again, and an abandoned session was effectively immortal. + +**Revoked tombstones are deliberately spared.** The `onlyWhen` filter (#10165) +is load-bearing, not defensive: the #7732 revocation write backdates +`expires_at` to `now - 1000` and clears nothing, so an ADR-0069 D4 audit +tombstone looks *maximally* expired — a TTL on `expires_at` without the filter +would reap the audit trail first and hardest. + +Deliberate, known consequence: because tombstones are spared entirely, +`sys_session` still grows without bound on the revoked arm. How long a +revoked-session tombstone should be retained is compliance / audit-trail +policy and is not settled here. diff --git a/packages/platform-objects/src/identity/sys-session-lifecycle.test.ts b/packages/platform-objects/src/identity/sys-session-lifecycle.test.ts new file mode 100644 index 0000000000..c4b185c797 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-session-lifecycle.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7826] `sys_session`'s ADR-0057 lifecycle declaration, at the SPEC tier. +// +// This is the third control on the card: the declaration parses, and neither +// of #10165's two `ttl.onlyWhen` conflict refines fires for it. +// +// ⚠️ "Neither refine fires" is worth nothing as a bare absence — `sys_session` +// declares no `archive` and no rotation `storage`, so of course they do not +// fire, and the same green would be printed by a build in which both refines +// had been deleted. So each is measured against its own counterfactual: the +// exact declaration plus the conflicting block must be REFUSED, with #10165's +// own message. That turns "no refine fired" into a statement about live rules. +// +// The sweep behaviour these keys buy — the tombstone-sparing and positive +// controls — is measured where a real Reaper and a real SQL backend are +// reachable: `@objectstack/plugin-auth`'s `sys-session-ttl-sweep.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { LifecycleSchema, ObjectSchema } from '@objectstack/spec/data'; +import { SysSession } from './sys-session.object.js'; +import { SysDeviceCode } from './sys-device-code.object.js'; + +const lifecycle = (SysSession as any).lifecycle; + +describe('[#7826] sys_session lifecycle declaration', () => { + it('is exactly the ruled declaration (maintainer 2026-08-20, option A)', () => { + expect(lifecycle).toEqual({ + class: 'transient', + ttl: { + field: 'expires_at', + expireAfter: '1d', + onlyWhen: { revoked_at: { $null: true } }, + }, + }); + }); + + it('parses — both as a lifecycle block and as part of the whole object', () => { + expect(LifecycleSchema.safeParse(lifecycle).success).toBe(true); + const parsed = ObjectSchema.safeParse(SysSession); + expect(parsed.success).toBe(true); + }); + + it('filters on a field the object actually declares, of a nullable type', () => { + // A filter naming a column that does not exist would compile to a + // predicate matching nothing — the sweep would silently stop reaping. + const field: any = (SysSession.fields as any)[Object.keys(lifecycle.ttl.onlyWhen)[0]]; + expect(field).toBeTruthy(); + expect(field.required).not.toBe(true); + expect((SysSession.fields as any)[lifecycle.ttl.field]).toBeTruthy(); + }); + + it('matches the window of sys_device_code, the only other better-auth transient object', () => { + expect((SysDeviceCode as any).lifecycle.class).toBe('transient'); + expect((SysDeviceCode as any).lifecycle.ttl.expireAfter).toBe(lifecycle.ttl.expireAfter); + }); + + // ── #10165's two refines: not fired here, and proved to be live ────────── + + it('declares neither conflicting block, so neither #10165 refine fires', () => { + expect(lifecycle.archive).toBeUndefined(); + expect(lifecycle.storage).toBeUndefined(); + }); + + it('COUNTERFACTUAL — adding `archive` to this exact declaration is refused', () => { + const r = LifecycleSchema.safeParse({ ...lifecycle, archive: { after: '7y', to: 'cold_store' } }); + expect(r.success).toBe(false); + expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | ')) + .toContain('lifecycle.ttl.onlyWhen cannot be combined with archive'); + }); + + it('COUNTERFACTUAL — adding rotation storage to this exact declaration is refused', () => { + const r = LifecycleSchema.safeParse({ + ...lifecycle, + storage: { strategy: 'rotation', shards: 7, unit: 'day' }, + }); + expect(r.success).toBe(false); + expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | ')) + .toContain('lifecycle.ttl.onlyWhen cannot be combined with rotation storage'); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index f19d2298fe..0ff50149d5 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -21,6 +21,37 @@ export const SysSession = ObjectSchema.create({ icon: 'key', isSystem: true, managedBy: 'better-auth', + + // [#7826] ADR-0057 lifecycle — ordinary expired sessions are swept by the + // Reaper; revoked TOMBSTONES are spared ENTIRELY. + // + // `onlyWhen` here is load-bearing, not defensive. The #7732 tombstone write + // (`plugin-auth`'s `reconcileSessionDelete`) BACKDATES `expires_at` to + // `now - 1000` and clears nothing, so a tombstone is a strict SUPERSET of an + // ordinary row that looks MAXIMALLY expired. A `ttl` keyed on `expires_at` + // without this filter would therefore reap the ADR-0069 D4 audit records + // FIRST AND HARDEST — the very rows it exists to preserve — and no existing + // test would go red. The canonical null predicate (`{$null: true}`, #10165) + // is what lets that exclusion be declared HERE, in the object file, instead + // of hiding in a plugin registration one package away. + // + // ⚠️ Deliberate consequence, stated rather than left implicit: tombstones are + // never swept, so `sys_session` still grows without bound on that arm. How + // long a revoked-session tombstone is retained is compliance / audit-trail + // policy and is the maintainer's to settle (#7826's hard fence) — this + // declaration picks no window for it. + // + // `1d` (a grace day AFTER `expires_at` passes) matches `sys_device_code`, + // the only other `managedBy: 'better-auth'` transient object. + lifecycle: { + class: 'transient', + ttl: { + field: 'expires_at', + expireAfter: '1d', + onlyWhen: { revoked_at: { $null: true } }, + }, + }, + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema, // but may add overlay row-level config. Use `no-overlay` if you need to // forbid sys_metadata overlays entirely. diff --git a/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts new file mode 100644 index 0000000000..e8574ef710 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7826] `sys_session`'s ADR-0057 TTL sweep, driven end to end: the REAL +// declaration (`@objectstack/platform-objects`) through the REAL Reaper +// (`@objectstack/objectql` `LifecycleService`) against a REAL SQL backend +// (`@objectstack/driver-sql`, live better-sqlite3), over a table this driver +// created from that same declaration. +// +// ## Why this suite exists at all +// +// The declaration it exercises is +// +// ttl: { field: 'expires_at', expireAfter: '1d', +// onlyWhen: { revoked_at: { $null: true } } } +// +// and the `onlyWhen` clause is the whole point. `reconcileSessionDelete` in +// `session-tombstone.ts` (#7732 / ADR-0069 D4) BACKDATES `expires_at` to +// `now - 1000` when it tombstones a revoked session, and clears nothing — so a +// tombstone is a strict SUPERSET of an ordinary row that looks MAXIMALLY +// expired. A TTL keyed on `expires_at` without the filter therefore reaps the +// audit records FIRST AND HARDEST. That backdating is pinned independently in +// `session-tombstone.test.ts`; here it is produced by that same function and +// then fed to the sweep, so the row under test is the one production writes +// rather than one this file imagined. +// +// ## The two controls, and why neither is sufficient alone +// +// * SPARING — the tombstone survives the sweep. +// * POSITIVE — an ordinary expired row is deleted BY THE SAME SWEEP. +// +// Without the positive control the filter could be disabling the sweep +// outright and the sparing control would still pass; without the sparing +// control the sweep is just a sweep. They are made maximally discriminating by +// giving both rows the IDENTICAL `expires_at`: the only property that differs +// is `revoked_at`, so nothing but the filter can separate their fates. +// +// ⚠️ The honest before-state for the sparing control is NOT the pre-fix tree: +// on `origin/main` `sys_session` declared no `lifecycle` at all, so there was +// no sweep and a tombstone survived trivially. The control was proved to +// discriminate by ABLATING the declaration itself — dropping `onlyWhen` while +// keeping the `ttl`, rebuilding `@objectstack/platform-objects` (this package +// resolves it through `exports`, i.e. `dist/`) and watching the tombstone get +// reaped. See the PR body for that run. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { LifecycleService, assertEngineDeleteDispatch } from '@objectstack/objectql'; +import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql'; +import { runWithEndpointContext } from '@better-auth/core/context'; +import { SysSession } from '@objectstack/platform-objects/identity'; +import { reconcileSessionDelete } from './session-tombstone'; + +/** The instant the revocation happens; the sweep runs two days later. */ +const REVOKED_AT_MS = Date.parse('2026-08-01T00:00:00.000Z'); +const SWEEP_AT_MS = REVOKED_AT_MS + 2 * 86_400_000; + +const openDrivers: SqlDriver[] = []; +afterEach(async () => { + while (openDrivers.length) { + const d = openDrivers.pop(); + try { await d?.disconnect(); } catch { /* noop */ } + } +}); + +const silentLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }; + +/** + * The tombstone patch as the REAL writer composes it — `reconcileSessionDelete` + * under a real better-auth endpoint context for an interactive revoke. Only the + * `update` surface is needed: the function answers the delete by writing this + * patch instead of deleting. + */ +async function realTombstonePatch(atMs = REVOKED_AT_MS): Promise> { + const patches: Array> = []; + const engine = { update: async (_o: string, p: any) => { patches.push(p); } }; + // The writer stamps from `Date.now()`. Pinning the clock to the simulated + // revocation instant is what puts the row on the same timeline as the sweep, + // WITHOUT rebasing (and so possibly flattening) the backdating this suite is + // about — the offset is still the one the real function chose. + vi.useFakeTimers(); + vi.setSystemTime(new Date(atMs)); + try { + const proceed = await runWithEndpointContext( + { path: '/revoke-session', context: {} } as any, + () => reconcileSessionDelete(engine as any, 'sys_session', { id: 'sess_tombstone', revoked_at: null }), + ); + expect(proceed).toBe(false); // answered by a tombstone, not a delete + } finally { + vi.useRealTimers(); + } + expect(patches).toHaveLength(1); + return patches[0]; +} + +/** + * `LifecycleEngineLike` over a live `SqlDriver`. `delete` opens with ObjectQL's + * own dispatch predicate so this double refuses exactly what the real engine + * refuses (#4550) rather than re-deriving the rule. + */ +function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): LifecycleEngineLike { + return { + registry: { getAllObjects: () => objects }, + getDriverForObject: () => driver, + async find(object: string, options: any) { + return driver.find(object, { where: options?.where, limit: options?.limit } as any); + }, + async delete(object: string, options: any) { + const dispatch = assertEngineDeleteDispatch(options); + if (dispatch.kind === 'by-id') return (await driver.delete(object, dispatch.id as any)) ? 1 : 0; + return driver.deleteMany(object, { where: options?.where } as any); + }, + }; +} + +/** + * Live `sys_session` table, created by the driver from the REAL object + * declaration, seeded with the three rows the policy has to tell apart. + * + * `lifecycle` is the declaration under test unless `override` replaces it — + * that parameter is what lets the ablation be expressed as a case in this file + * as well as being run for real against a rebuilt `dist/` (see the header). + */ +async function seeded(override?: any) { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + openDrivers.push(driver); + await driver.initObjects([SysSession as any]); + + const patch = await realTombstonePatch(); + const tombstoneExpiry = new Date(patch.expires_at).toISOString(); + + await driver.create('sys_session', { + id: 'sess_tombstone', + user_id: 'usr_1', + token: 'tok_tombstone', + // Exactly what the real tombstone writer produced. + expires_at: tombstoneExpiry, + revoked_at: new Date(patch.revoked_at).toISOString(), + revoke_reason: patch.revoke_reason, + }); + await driver.create('sys_session', { + id: 'sess_expired', + user_id: 'usr_1', + token: 'tok_expired', + // IDENTICAL expiry to the tombstone — `revoked_at` is the only difference. + expires_at: tombstoneExpiry, + revoked_at: null, + }); + await driver.create('sys_session', { + id: 'sess_live', + user_id: 'usr_1', + token: 'tok_live', + expires_at: new Date(SWEEP_AT_MS + 7 * 86_400_000).toISOString(), + revoked_at: null, + }); + + const object: LifecycleObjectLike = { + name: SysSession.name, + lifecycle: (override === undefined ? (SysSession as any).lifecycle : override), + fields: SysSession.fields as any, + }; + const service = new LifecycleService({ + getEngine: () => sweepEngine(driver, [object]), + logger: silentLogger, + now: () => SWEEP_AT_MS, + initialDelayMs: 1, + sweepIntervalMs: 10, + } as any); + + return { driver, service, patch, tombstoneExpiry }; +} + +const survivors = async (driver: SqlDriver) => + (await driver.find('sys_session', {} as any)).map((r: any) => r.id).sort(); + +describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live SQL', () => { + it('the hazard is real: the tombstone writer backdates expires_at below the revocation instant', async () => { + const patch = await realTombstonePatch(); + expect(patch.revoked_at).toBeInstanceOf(Date); + expect(patch.revoke_reason).toBeTruthy(); + // The defining property: the tombstone looks MORE expired than a session + // that merely lapsed, which is why a naive TTL reaps tombstones first. + expect(new Date(patch.expires_at).getTime()).toBeLessThan(new Date(patch.revoked_at).getTime()); + }); + + it('SPARING CONTROL — the revoked tombstone survives the sweep', async () => { + const { driver, service } = await seeded(); + + const report = await service.sweep(); + + expect(await survivors(driver)).toContain('sess_tombstone'); + const row: any = await driver.findOne('sys_session', { where: { id: 'sess_tombstone' } } as any); + expect(row).toBeTruthy(); + expect(row.revoke_reason).toBeTruthy(); // the audit content is intact + expect(report.errors).toEqual([]); + }); + + it('POSITIVE CONTROL — an ordinary expired session IS deleted by that same sweep', async () => { + const { driver, service } = await seeded(); + + const report = await service.sweep(); + + // One sweep, three rows, two verdicts: the expired row is gone, the + // tombstone and the live session remain. + expect(await survivors(driver)).toEqual(['sess_live', 'sess_tombstone']); + const ttl = report.swept.find((s: any) => s.object === 'sys_session' && s.policy === 'ttl'); + expect(ttl).toBeTruthy(); + expect(ttl!.deleted).toBe(1); + }); + + it('ABLATION — without `onlyWhen` the same sweep reaps the tombstone too', async () => { + // The declaration minus its filter: the naive policy #10165 existed to + // make avoidable. This is the case the sparing control has to discriminate + // against, so the control is not vacuous. + const { driver, service } = await seeded({ + class: 'transient', + ttl: { field: 'expires_at', expireAfter: '1d' }, + }); + + await service.sweep(); + + expect(await survivors(driver)).toEqual(['sess_live']); + }); +}); From 9b6c7665e2f22c1b2dd62e1577f7613a70fd7575 Mon Sep 17 00:00:00 2001 From: os-elon Date: Fri, 21 Aug 2026 04:28:00 +0000 Subject: [PATCH 2/3] test(plugin-auth): type the sweep double's driver queries, record its pinned delete seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:query-options-erasure counted 3 new test-surface sites from the LifecycleEngineLike double's driver calls. Fixed at the author's end — the query bags are now typed `DriverQuery` instead of erased to `any` — rather than by raising the ratchet's ceiling. check:engine-double-contract wanted the file's delete() seam recorded: the double already routes through assertEngineDeleteDispatch, so this records new PINNED coverage (engine-double-contract.pinned.json); the shrink-only debt baseline is untouched ("0 added or grown, 0 lost"). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../src/sys-session-ttl-sweep.test.ts | 18 +++++++++++++----- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts index e8574ef710..35d31260ed 100644 --- a/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts +++ b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts @@ -46,6 +46,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { SqlDriver } from '@objectstack/driver-sql'; import { LifecycleService, assertEngineDeleteDispatch } from '@objectstack/objectql'; import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql'; +import type { DriverQuery } from '@objectstack/spec/contracts'; import { runWithEndpointContext } from '@better-auth/core/context'; import { SysSession } from '@objectstack/platform-objects/identity'; import { reconcileSessionDelete } from './session-tombstone'; @@ -102,12 +103,17 @@ function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): Lifecyc registry: { getAllObjects: () => objects }, getDriverForObject: () => driver, async find(object: string, options: any) { - return driver.find(object, { where: options?.where, limit: options?.limit } as any); + // Typed rather than erased to `any`: the driver silently DROPS an + // unrecognised query key, so `tsc` is the only channel that can reject a + // misspelt one here (#4918). + const query: DriverQuery = { where: options?.where, limit: options?.limit }; + return driver.find(object, query); }, async delete(object: string, options: any) { const dispatch = assertEngineDeleteDispatch(options); - if (dispatch.kind === 'by-id') return (await driver.delete(object, dispatch.id as any)) ? 1 : 0; - return driver.deleteMany(object, { where: options?.where } as any); + if (dispatch.kind === 'by-id') return (await driver.delete(object, dispatch.id)) ? 1 : 0; + const query: DriverQuery = { where: options?.where }; + return driver.deleteMany(object, query); }, }; } @@ -173,8 +179,9 @@ async function seeded(override?: any) { return { driver, service, patch, tombstoneExpiry }; } +const ALL_ROWS: DriverQuery = {}; const survivors = async (driver: SqlDriver) => - (await driver.find('sys_session', {} as any)).map((r: any) => r.id).sort(); + (await driver.find('sys_session', ALL_ROWS)).map((r: any) => r.id).sort(); describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live SQL', () => { it('the hazard is real: the tombstone writer backdates expires_at below the revocation instant', async () => { @@ -192,7 +199,8 @@ describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live const report = await service.sweep(); expect(await survivors(driver)).toContain('sess_tombstone'); - const row: any = await driver.findOne('sys_session', { where: { id: 'sess_tombstone' } } as any); + const tombstoneById: DriverQuery = { where: { id: 'sess_tombstone' } }; + const row: any = await driver.findOne('sys_session', tombstoneById); expect(row).toBeTruthy(); expect(row.revoke_reason).toBeTruthy(); // the audit content is intact expect(report.errors).toEqual([]); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7dfad72e23..787ad7a3e3 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1191,6 +1191,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/plugins/plugin-email/src/attachment-reclaim.test.ts", "verb": "delete", From 950f45970373c68f855255702a12e061d711b4c2 Mon Sep 17 00:00:00 2001 From: os-elon Date: Fri, 21 Aug 2026 05:08:39 +0000 Subject: [PATCH 3/3] test(plugin-auth): narrow the dispatch id's bigint arm instead of casting it check:type-check-debt --re-measure caught plugin-auth's TEST_DEBT drifting 109 -> 110: EngineDeleteDispatch.id admits bigint while the driver's by-id delete takes string | number, a mismatch the earlier `as any` had hidden. Narrowed at the author's end (stringify, as LifecycleService's own idKey does); the shrink-only ledger is untouched and re-measures at 109. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../plugin-auth/src/sys-session-ttl-sweep.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts index 35d31260ed..a43e036e1a 100644 --- a/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts +++ b/packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts @@ -111,7 +111,14 @@ function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): Lifecyc }, async delete(object: string, options: any) { const dispatch = assertEngineDeleteDispatch(options); - if (dispatch.kind === 'by-id') return (await driver.delete(object, dispatch.id)) ? 1 : 0; + if (dispatch.kind === 'by-id') { + // `EngineDeleteDispatch.id` admits `bigint`; the driver's by-id delete + // takes `string | number`. Narrowed by stringifying — the same reason + // `LifecycleService`'s own `idKey` stringifies — rather than cast away, + // which is what hid the mismatch here in the first place. + const id = typeof dispatch.id === 'bigint' ? dispatch.id.toString() : dispatch.id; + return (await driver.delete(object, id)) ? 1 : 0; + } const query: DriverQuery = { where: options?.where }; return driver.deleteMany(object, query); },