diff --git a/.changeset/lifecycle-rotation-dialect-caveat.md b/.changeset/lifecycle-rotation-dialect-caveat.md new file mode 100644 index 0000000000..0f734aae37 --- /dev/null +++ b/.changeset/lifecycle-rotation-dialect-caveat.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): `lifecycle.storage` 的 rotation 文案补上方言限定 —— O(1) 整分片 DROP 仅在 SQLite 成立 (#6631) + +`lifecycle.storage` 块的三处文案把 SQLite 独有的机制写成了 rotation 策略的无条件属性: +`maxAge` guidance 的 "Rotation does not reap by age — it ... DROPs the oldest shard +whole"、`strategy` describe 的 "(O(1) reclaim)"、以及模块 TSDoc 的 "Rotator +(time-shard + DROP oldest)"。实测权威:物理分片是 SQLite-only 的驱动能力 +(`driver-sql` 的 `supportsRotation` 只在 `isSqlite` 下为 true,`rotateShards` +在其他方言直接拒绝),Postgres/MySQL 走 LifecycleService 的 `'rotation-fallback'` +分支 —— 按 `created_at` 的年龄批量 reap,恰是旧文案宣称 rotation 不使用的机制。 + +三处文案改为驱动注释早已写对的表述:保留窗口(`shards` × `unit`)在所有方言上 +一致 —— 声明的边界处处成立;回收机制不一致 —— SQLite 整分片 DROP(O(1) 回收), +其他方言按年龄 reap 同一窗口。`maxAge` guidance 的路由建议(用 `shards`/`unit` +设窗口,或改用 `retention`)原样保留。 + +**纯文案修改,接受面零变化**:`LifecycleSchema` 接受/拒绝的输入集合与改动前 +逐字节相同;`superRefine`(含 `retention.onlyWhen` × rotation 的拒绝及其理由) +未触碰。批 20 测试新增两条 pin:guidance 与 describe 必须同时点名两条腿 +(SQLite 的分片 DROP 与其他方言的按龄 reap),并各带反空洞守卫,防止整段文案 +消失时 pin 静默变绿。 diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index 846258e99d..b7bb5a4565 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -252,6 +252,68 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak expect(msg).toContain('retention'); }); + // #6631 — `lifecycle.storage.maxAge` is the third sideways pointer, and the + // mechanism it contrasted was true on exactly one dialect. Physical rotation + // is SQLite-only: `driver-sql` gates it behind + // `get supportsRotation() { return this.isSqlite }` and `rotateShards` throws + // on every other dialect (`packages/drivers/driver-sql/src/sql-driver.ts`). + // The LifecycleService then takes its `'rotation-fallback'` leg — + // `reap(…, 'rotation-fallback', 'created_at', windowMs, …)` in + // `packages/objectql/src/lifecycle/lifecycle-service.ts` — which is a reap BY + // AGE from `created_at`, precisely the mechanism the old guidance told the + // author rotation does not use. What the author most needs to keep believing + // still holds: the retained WINDOW is identical on every dialect. Only the + // reclamation is not — which is what the driver's own note directly above + // `supportsRotation` already says, and what these two surfaces now say too. + // + // ⛔ Scope: the qualifier, not the wording. Matched by idiom — does the text + // name the split at all? — so a rewrite is free and dropping the caveat is + // not. Nothing here asserts anything about what `LifecycleSchema` ACCEPTS, + // which is unchanged (the `onlyWhen`-vs-rotation `superRefine` carries the + // same dialect-specific reason and is deliberately left alone). + it('`lifecycle.storage.maxAge` points SIDEWAYS at `retention` — and qualifies the mechanism it contrasts, which is SQLite-only (#6631)', () => { + const rotating = { strategy: 'rotation', shards: 7, unit: 'day' } as const; + const msg = rejectOnObject({ lifecycle: { class: 'telemetry', storage: { ...rotating, maxAge: '7d' } } }); + + // Anti-vacuity. `strictObject` rejects an unknown key with or WITHOUT a + // `guidance` entry, so every assertion below would pass on a deleted entry. + // Anchor on the routing advice — the half of this message that was correct + // all along and must survive any rewording of the other half. + expect(msg, 'the `maxAge` guidance bullet is not being produced at all').toContain( + 'Set the window with `shards`/`unit`, or use `retention` instead of rotation.', + ); + // …and it really is THIS key's guidance rather than something the surface + // says to every unknown key: a sibling unknown key gets the bare rejection. + expect(rejectOnObject({ lifecycle: { class: 'telemetry', storage: { ...rotating, notAStorageKey: 1 } } })) + .not.toMatch(/SQLite/i); + + // The pointed-at spelling really does parse (finding 18 — a prescription + // that does not work is worse than none). + accept(ObjectSchema, { ...OBJ, lifecycle: { class: 'telemetry', storage: rotating, retention: { maxAge: '7d' } } }); + + // The claim under test: both legs of the split are named. + expect(msg, 'the shard DROP must be attributed to SQLite').toMatch(/SQLite/); + expect(msg, 'and the other dialects must be told what they get instead').toMatch(/age-based reap|reaps? [^.]{0,32}by age/i); + + // The retired unconditional claim, pinned by name so it cannot come back + // as a paraphrase of the same idea — it is false on Postgres/MySQL, where + // the fallback leg reaps from `created_at` exactly by age. + expect(msg).not.toContain('does not reap by age'); + }); + + it("`lifecycle.storage.strategy`'s description carries the same qualifier — `(O(1) reclaim)` is a property of SQLite, not of the strategy (#6631)", () => { + const doc = LifecycleSchema.shape.storage.unwrap().shape.strategy.description ?? ''; + + // Anti-vacuity: an empty description satisfies every negative assertion + // below, and is exactly how a pin like this rots into decoration. + expect(doc.length, '`storage.strategy` lost its `.describe()`').toBeGreaterThan(0); + expect(doc, 'the description no longer describes the rotation mechanism at all').toMatch(/shard/i); + + expect(doc, 'the O(1) shard DROP must be attributed to SQLite').toMatch(/SQLite/); + expect(doc, 'and the other dialects must be told what they get instead').toMatch(/age-based|by age/i); + expect(doc).not.toContain('rotate by DROPping the oldest shard (O(1) reclaim)'); + }); + it('`access.sharingModel` points UP — it is a real TOP-LEVEL key, so distance would never find it', () => { const msg = rejectOnObject({ access: { default: 'private', sharingModel: 'private' } }); expect(msg).toContain('TOP-LEVEL'); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 53da385766..7cdd69a5d9 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -600,7 +600,8 @@ export type ObjectRequiredPermissions = z.input strictObject({ aliases: { count: 'shards', interval: 'unit', period: 'unit', granularity: 'unit' }, guidance: { maxAge: - '`maxAge` is a `retention` key. Rotation does not reap by age — it retains ' + - '`shards` × `unit` of history and DROPs the oldest shard whole. Set the window ' + + '`maxAge` is a `retention` key. Rotation takes its window from `shards` × `unit`, ' + + 'not from an age you name — reclaimed by DROPping the oldest shard whole on SQLite, ' + + 'by an equivalent age-based reap elsewhere. Set the window ' + 'with `shards`/`unit`, or use `retention` instead of rotation.', }, }, { - strategy: z.literal('rotation').describe('Time-shard the table; rotate by DROPping the oldest shard (O(1) reclaim).'), + strategy: z.literal('rotation').describe( + 'Time-shard the table. The retained window (`shards` × `unit`) is the same on every ' + + 'dialect; the reclamation is not — SQLite DROPs the oldest shard whole (O(1) reclaim), ' + + 'other dialects reap that same window by age from `created_at`.', + ), shards: z.number().int().min(2).describe('Number of shards retained; total window = shards × unit.'), unit: z.enum(['day', 'week', 'month']).describe('Time width of one shard.'), }).optional().describe('Physical storage strategy for high-frequency telemetry (LifecycleService Rotator).'), diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 8756c8d001..6afac051a6 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -33,7 +33,7 @@ "src/data/driver.test.ts": 11, "src/data/driver/memory.test.ts": 1, "src/data/field.test.ts": 2, - "src/data/object-strictness-batch20.test.ts": 2, + "src/data/object-strictness-batch20.test.ts": 1, "src/data/query.test.ts": 25, "src/identity/scim.test.ts": 7, "src/integration/connector.test.ts": 7,