|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #17611 — the ruled declaration (director seat, decision batch #116 item 3, |
| 4 | +// maintainer verbatim and untranslated: 「17611 同意」 to 「席位推荐 C 现在做,A |
| 5 | +// 另立一卡」), driven end to end. |
| 6 | +// |
| 7 | +// The card: a tenant with no email transport dead-letters the `email` row of |
| 8 | +// every fanned-out `notify` on its first attempt, and those rows then sat in |
| 9 | +// the claim query's table for 90 days — 2,876 of them at +316/day on the |
| 10 | +// reported production tenant, carrying no work anybody would ever do. |
| 11 | +// |
| 12 | +// The fix under test is a DECLARATION, so this suite is driven exactly as the |
| 13 | +// two sibling sweeps next door (`plugin-auth/sys-session-ttl-sweep.test.ts`, |
| 14 | +// `service-storage/sys-upload-session-ttl-sweep.test.ts`): the REAL |
| 15 | +// declaration (`objects/notification-delivery.object.ts`) through the REAL |
| 16 | +// provisioning pass (`applySystemFields`) through the REAL Reaper |
| 17 | +// (`@objectstack/objectql` `LifecycleService`) against a REAL SQL backend |
| 18 | +// (`@objectstack/driver-sql`, live better-sqlite3), over a table this driver |
| 19 | +// created from that same declaration. Nothing here restates a window by hand — |
| 20 | +// the policy under test is `NotificationDelivery.lifecycle` itself, so an edit |
| 21 | +// that drops or narrows it reddens this file rather than silently voiding the |
| 22 | +// ruling. |
| 23 | +// |
| 24 | +// ## The two sides the ruling asked to see pinned, and the third this adds |
| 25 | +// |
| 26 | +// 1. TERMINAL rows past 7d ARE selected — `dead` and `suppressed`. |
| 27 | +// 2. `pending` / `success` under 90d are NOT — the acceptance's own words. |
| 28 | +// 3. ⚠️ …and a `success` row past 90d IS still reaped. Scoping `retention` |
| 29 | +// to terminal statuses would otherwise have UNBOUNDED the non-terminal |
| 30 | +// rows entirely (`retention` is one block), so this third leg is what |
| 31 | +// distinguishes "the table window was kept" from "the table window was |
| 32 | +// traded away on the card that exists to tighten it". |
| 33 | +// |
| 34 | +// ## Why the reading is discriminating |
| 35 | +// |
| 36 | +// `d_dead_30d` and `d_success_30d` carry the IDENTICAL `created_at`, so no age |
| 37 | +// rule can separate their fates — only the `onlyWhen` status filter can. And |
| 38 | +// `d_dead_2d` is terminal but inside the 7d window, so "everything terminal is |
| 39 | +// reaped" fails too: both halves of the scoped policy are load-bearing. |
| 40 | +// |
| 41 | +// ## The counterfactual |
| 42 | +// |
| 43 | +// `seeded({ lifecycle: … })` hands the SAME service the same rows under an |
| 44 | +// ABLATED declaration — the 7d retention with its `onlyWhen` removed — and the |
| 45 | +// non-terminal rows are reaped along with the terminal ones. That is what |
| 46 | +// rules out "the sweep would have produced this result anyway". ⚠️ It is |
| 47 | +// deliberately NOT described as a dist-level ablation: the declaration under |
| 48 | +// test is this package's own source, imported directly, so no rebuild is |
| 49 | +// involved and none is claimed. The on-disk ablation of the shipped file is |
| 50 | +// recorded in the PR body. |
| 51 | + |
| 52 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 53 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 54 | +import { LifecycleService, applySystemFields, assertEngineDeleteDispatch } from '@objectstack/objectql'; |
| 55 | +import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql'; |
| 56 | +import type { DriverQuery } from '@objectstack/spec/contracts'; |
| 57 | +import { NotificationDelivery } from './objects/notification-delivery.object.js'; |
| 58 | + |
| 59 | +const DAY_MS = 86_400_000; |
| 60 | +/** The instant the sweep runs. Every age below is expressed against it. */ |
| 61 | +const SWEEP_AT_MS = Date.parse('2026-09-12T00:00:00.000Z'); |
| 62 | +const agedDays = (days: number) => new Date(SWEEP_AT_MS - days * DAY_MS).toISOString(); |
| 63 | + |
| 64 | +/** Past the 7d terminal window, far inside the 90d table window. */ |
| 65 | +const AGE_30D = agedDays(30); |
| 66 | +/** Inside BOTH windows. */ |
| 67 | +const AGE_2D = agedDays(2); |
| 68 | +/** Past the 90d table window. */ |
| 69 | +const AGE_100D = agedDays(100); |
| 70 | + |
| 71 | +const openDrivers: SqlDriver[] = []; |
| 72 | +afterEach(async () => { |
| 73 | + while (openDrivers.length) { |
| 74 | + const d = openDrivers.pop(); |
| 75 | + try { |
| 76 | + await d?.disconnect(); |
| 77 | + } catch { |
| 78 | + /* noop */ |
| 79 | + } |
| 80 | + } |
| 81 | +}); |
| 82 | + |
| 83 | +const silentLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }; |
| 84 | + |
| 85 | +/** |
| 86 | + * `LifecycleEngineLike` over a live `SqlDriver`. `delete` opens with ObjectQL's |
| 87 | + * own dispatch predicate so this double refuses exactly what the real engine |
| 88 | + * refuses (#4550) rather than re-deriving the rule. |
| 89 | + */ |
| 90 | +function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): LifecycleEngineLike { |
| 91 | + return { |
| 92 | + registry: { getAllObjects: () => objects }, |
| 93 | + getDriverForObject: () => driver, |
| 94 | + async find(object: string, options: any) { |
| 95 | + // Typed rather than erased to `any`: the driver silently DROPS an |
| 96 | + // unrecognised query key, so `tsc` is the only channel that can reject a |
| 97 | + // misspelt one here (#4918). |
| 98 | + const query: DriverQuery = { where: options?.where, limit: options?.limit }; |
| 99 | + return driver.find(object, query); |
| 100 | + }, |
| 101 | + async delete(object: string, options: any) { |
| 102 | + const dispatch = assertEngineDeleteDispatch(options); |
| 103 | + if (dispatch.kind === 'by-id') { |
| 104 | + const id = typeof dispatch.id === 'bigint' ? dispatch.id.toString() : dispatch.id; |
| 105 | + return (await driver.delete(object, id)) ? 1 : 0; |
| 106 | + } |
| 107 | + const query: DriverQuery = { where: options?.where }; |
| 108 | + return driver.deleteMany(object, query); |
| 109 | + }, |
| 110 | + } as LifecycleEngineLike; |
| 111 | +} |
| 112 | + |
| 113 | +/** The object as the platform actually registers it (tenant field: organization_id). */ |
| 114 | +function provisioned() { |
| 115 | + return applySystemFields(NotificationDelivery as any, { multiTenant: true }) as any; |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * The six rows the policy has to tell apart. Every one of them is a shape the |
| 120 | + * ack paths really write: `SqlNotificationOutbox.ack` / `MemoryNotificationOutbox.ack` |
| 121 | + * produce exactly `success | suppressed | dead | pending`, and `claim()` writes |
| 122 | + * `in_flight`. |
| 123 | + */ |
| 124 | +const ROWS: Array<{ id: string; status: string; created_at: string; channel: string }> = [ |
| 125 | + // ── past the 7d terminal window ────────────────────────────────────────── |
| 126 | + { id: 'd_dead_30d', status: 'dead', created_at: AGE_30D, channel: 'email' }, |
| 127 | + { id: 'd_suppressed_30d', status: 'suppressed', created_at: AGE_30D, channel: 'email' }, |
| 128 | + // ── terminal, but INSIDE the 7d window ─────────────────────────────────── |
| 129 | + { id: 'd_dead_2d', status: 'dead', created_at: AGE_2D, channel: 'email' }, |
| 130 | + // ── non-terminal, same age as the reaped terminal rows ─────────────────── |
| 131 | + { id: 'd_success_30d', status: 'success', created_at: AGE_30D, channel: 'inbox' }, |
| 132 | + { id: 'd_pending_30d', status: 'pending', created_at: AGE_30D, channel: 'inbox' }, |
| 133 | + { id: 'd_in_flight_30d', status: 'in_flight', created_at: AGE_30D, channel: 'inbox' }, |
| 134 | + // ── non-terminal, past the 90d TABLE window ────────────────────────────── |
| 135 | + { id: 'd_success_100d', status: 'success', created_at: AGE_100D, channel: 'inbox' }, |
| 136 | +]; |
| 137 | + |
| 138 | +async function seeded(opts?: { lifecycle?: unknown }) { |
| 139 | + const schema = provisioned(); |
| 140 | + const driver = new SqlDriver({ |
| 141 | + client: 'better-sqlite3', |
| 142 | + connection: { filename: ':memory:' }, |
| 143 | + useNullAsDefault: true, |
| 144 | + }); |
| 145 | + openDrivers.push(driver); |
| 146 | + await driver.initObjects([schema]); |
| 147 | + |
| 148 | + for (const r of ROWS) { |
| 149 | + await driver.create('sys_notification_delivery', { |
| 150 | + id: r.id, |
| 151 | + // Distinct per row: the object declares `(notification_id, recipient_id, |
| 152 | + // channel)` UNIQUE, which is the real dedup key — one delivery per |
| 153 | + // (event × recipient × channel). Reusing one event id here would be a |
| 154 | + // shape production cannot produce. |
| 155 | + notification_id: `n_${r.id}`, |
| 156 | + recipient_id: 'usr_1', |
| 157 | + channel: r.channel, |
| 158 | + status: r.status, |
| 159 | + attempts: r.status === 'pending' ? 0 : 1, |
| 160 | + partition_key: 0, |
| 161 | + created_at: r.created_at, |
| 162 | + updated_at: r.created_at, |
| 163 | + organization_id: 'org_A', |
| 164 | + }); |
| 165 | + } |
| 166 | + |
| 167 | + const object: LifecycleObjectLike = { |
| 168 | + name: NotificationDelivery.name, |
| 169 | + lifecycle: |
| 170 | + opts && 'lifecycle' in opts ? (opts.lifecycle as any) : (NotificationDelivery as any).lifecycle, |
| 171 | + fields: schema.fields, |
| 172 | + } as LifecycleObjectLike; |
| 173 | + |
| 174 | + const service = new LifecycleService({ |
| 175 | + getEngine: () => sweepEngine(driver, [object]), |
| 176 | + logger: silentLogger, |
| 177 | + now: () => SWEEP_AT_MS, |
| 178 | + initialDelayMs: 1, |
| 179 | + sweepIntervalMs: 10, |
| 180 | + } as any); |
| 181 | + |
| 182 | + return { driver, service }; |
| 183 | +} |
| 184 | + |
| 185 | +const ALL_ROWS: DriverQuery = {}; |
| 186 | +const survivors = async (driver: SqlDriver) => |
| 187 | + (await driver.find('sys_notification_delivery', ALL_ROWS)).map((r: any) => r.id).sort(); |
| 188 | + |
| 189 | +describe('[#17611] sys_notification_delivery terminal retention — real declaration, real Reaper, live SQL', () => { |
| 190 | + it('is exactly the ruled declaration', () => { |
| 191 | + expect((NotificationDelivery as any).lifecycle).toEqual({ |
| 192 | + class: 'telemetry', |
| 193 | + ttl: { field: 'created_at', expireAfter: '90d' }, |
| 194 | + retention: { |
| 195 | + maxAge: '7d', |
| 196 | + onlyWhen: { status: { $in: ['dead', 'suppressed'] } }, |
| 197 | + }, |
| 198 | + }); |
| 199 | + }); |
| 200 | + |
| 201 | + it('scopes on statuses the field declares AND the ack paths really write', () => { |
| 202 | + // A filter naming a value the writers never produce would compile to a |
| 203 | + // predicate matching nothing — the sweep would silently reap nothing. |
| 204 | + const scoped: string[] = (NotificationDelivery as any).lifecycle.retention.onlyWhen.status.$in; |
| 205 | + const declared: string[] = (NotificationDelivery.fields as any).status.options.map( |
| 206 | + (o: any) => (typeof o === 'string' ? o : o.value), |
| 207 | + ); |
| 208 | + for (const s of scoped) expect(declared).toContain(s); |
| 209 | + // `success` is terminal too, and is deliberately OUT of the scope: the |
| 210 | + // ruling keeps delivery history at the table window. |
| 211 | + expect(scoped).not.toContain('success'); |
| 212 | + }); |
| 213 | + |
| 214 | + it('POSITIVE — terminal rows past 7d are reaped, and the retention leg is recorded', async () => { |
| 215 | + const { driver, service } = await seeded(); |
| 216 | + |
| 217 | + const report = await service.sweep(); |
| 218 | + |
| 219 | + const left = await survivors(driver); |
| 220 | + expect(left).not.toContain('d_dead_30d'); |
| 221 | + expect(left).not.toContain('d_suppressed_30d'); |
| 222 | + expect(report.errors).toEqual([]); |
| 223 | + const retention = report.swept.find( |
| 224 | + (e: any) => e.object === 'sys_notification_delivery' && e.policy === 'retention', |
| 225 | + ); |
| 226 | + expect(retention).toBeTruthy(); |
| 227 | + }); |
| 228 | + |
| 229 | + it('NEGATIVE — pending / success / in_flight rows of the SAME age are untouched', async () => { |
| 230 | + const { driver, service } = await seeded(); |
| 231 | + |
| 232 | + await service.sweep(); |
| 233 | + |
| 234 | + const left = await survivors(driver); |
| 235 | + // Identical `created_at` to the two rows just reaped, so nothing but the |
| 236 | + // `onlyWhen` status filter can be what spared them. |
| 237 | + expect(left).toContain('d_success_30d'); |
| 238 | + expect(left).toContain('d_pending_30d'); |
| 239 | + expect(left).toContain('d_in_flight_30d'); |
| 240 | + }); |
| 241 | + |
| 242 | + it('NEGATIVE — a terminal row INSIDE the 7d window is untouched', async () => { |
| 243 | + const { driver, service } = await seeded(); |
| 244 | + |
| 245 | + await service.sweep(); |
| 246 | + |
| 247 | + // Without this the suite would pass for a policy that reaped every |
| 248 | + // terminal row at any age. |
| 249 | + expect(await survivors(driver)).toContain('d_dead_2d'); |
| 250 | + }); |
| 251 | + |
| 252 | + it('the TABLE window survived — a non-terminal row past 90d is still reaped by the ttl leg', async () => { |
| 253 | + const { driver, service } = await seeded(); |
| 254 | + |
| 255 | + const report = await service.sweep(); |
| 256 | + |
| 257 | + expect(await survivors(driver)).not.toContain('d_success_100d'); |
| 258 | + const ttl = report.swept.find( |
| 259 | + (e: any) => e.object === 'sys_notification_delivery' && e.policy === 'ttl', |
| 260 | + ); |
| 261 | + expect(ttl).toBeTruthy(); |
| 262 | + }); |
| 263 | + |
| 264 | + it('one sweep, seven rows, the whole verdict in one assertion', async () => { |
| 265 | + const { driver, service } = await seeded(); |
| 266 | + |
| 267 | + await service.sweep(); |
| 268 | + |
| 269 | + expect(await survivors(driver)).toEqual([ |
| 270 | + 'd_dead_2d', |
| 271 | + 'd_in_flight_30d', |
| 272 | + 'd_pending_30d', |
| 273 | + 'd_success_30d', |
| 274 | + ]); |
| 275 | + }); |
| 276 | + |
| 277 | + it('COUNTERFACTUAL — drop `onlyWhen` and the same sweep takes the non-terminal rows too', async () => { |
| 278 | + // The naive policy: a 7d table-wide retention. This is what the negative |
| 279 | + // pins above have to discriminate against, so they are not vacuous. |
| 280 | + const { driver, service } = await seeded({ |
| 281 | + lifecycle: { |
| 282 | + class: 'telemetry', |
| 283 | + ttl: { field: 'created_at', expireAfter: '90d' }, |
| 284 | + retention: { maxAge: '7d' }, |
| 285 | + }, |
| 286 | + }); |
| 287 | + |
| 288 | + await service.sweep(); |
| 289 | + |
| 290 | + expect(await survivors(driver)).toEqual(['d_dead_2d']); |
| 291 | + }); |
| 292 | + |
| 293 | + it('COUNTERFACTUAL — with no lifecycle declaration the same sweep reaps nothing', async () => { |
| 294 | + const { driver, service } = await seeded({ lifecycle: null }); |
| 295 | + |
| 296 | + const report = await service.sweep(); |
| 297 | + |
| 298 | + expect(await survivors(driver)).toEqual(ROWS.map((r) => r.id).sort()); |
| 299 | + expect(report.swept.filter((e: any) => e.object === 'sys_notification_delivery')).toEqual([]); |
| 300 | + }); |
| 301 | +}); |
0 commit comments