Skip to content

Commit 9b86cf6

Browse files
docs(spec): lifecycle.storage rotation 文案补上方言限定 — O(1) 分片 DROP 仅 SQLite (#6631) (#6789)
The maxAge guidance, the strategy .describe() and the module TSDoc stated the SQLite-only shard-DROP mechanism as an unconditional property of the rotation strategy. Measured authority: driver-sql gates physical rotation behind supportsRotation (isSqlite only) and rotateShards throws elsewhere; the LifecycleService then takes its 'rotation-fallback' leg — an age-based reap from created_at, exactly the mechanism the old guidance said rotation does not use. All three sites now carry the split the driver note already states: the retained window (shards x unit) is identical on every dialect; only the reclamation mechanics differ (SQLite DROPs the oldest shard whole, O(1); other dialects reap the same window by age). The maxAge guidance's routing advice survives verbatim. Text-only: LifecycleSchema's acceptance surface is unchanged and the superRefine is untouched. Two pins added in batch 20 (both with anti-vacuity guards, matched by idiom): the guidance bullet and the strategy description must each name both legs; reverse-verified red on the reverted text, green on this one. test-typecheck-debt re-recorded 2 -> 1 for the batch-20 file (LifecycleSchema is now imported, retiring its unused-import entry). Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk Co-authored-by: Claude <noreply@anthropic.com>
1 parent e787608 commit 9b86cf6

4 files changed

Lines changed: 97 additions & 5 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `lifecycle.storage` 的 rotation 文案补上方言限定 —— O(1) 整分片 DROP 仅在 SQLite 成立 (#6631)
6+
7+
`lifecycle.storage` 块的三处文案把 SQLite 独有的机制写成了 rotation 策略的无条件属性:
8+
`maxAge` guidance 的 "Rotation does not reap by age — it ... DROPs the oldest shard
9+
whole"、`strategy` describe 的 "(O(1) reclaim)"、以及模块 TSDoc 的 "Rotator
10+
(time-shard + DROP oldest)"。实测权威:物理分片是 SQLite-only 的驱动能力
11+
(`driver-sql``supportsRotation` 只在 `isSqlite` 下为 true,`rotateShards`
12+
在其他方言直接拒绝),Postgres/MySQL 走 LifecycleService 的 `'rotation-fallback'`
13+
分支 —— 按 `created_at` 的年龄批量 reap,恰是旧文案宣称 rotation 不使用的机制。
14+
15+
三处文案改为驱动注释早已写对的表述:保留窗口(`shards` × `unit`)在所有方言上
16+
一致 —— 声明的边界处处成立;回收机制不一致 —— SQLite 整分片 DROP(O(1) 回收),
17+
其他方言按年龄 reap 同一窗口。`maxAge` guidance 的路由建议(用 `shards`/`unit`
18+
设窗口,或改用 `retention`)原样保留。
19+
20+
**纯文案修改,接受面零变化**:`LifecycleSchema` 接受/拒绝的输入集合与改动前
21+
逐字节相同;`superRefine`(含 `retention.onlyWhen` × rotation 的拒绝及其理由)
22+
未触碰。批 20 测试新增两条 pin:guidance 与 describe 必须同时点名两条腿
23+
(SQLite 的分片 DROP 与其他方言的按龄 reap),并各带反空洞守卫,防止整段文案
24+
消失时 pin 静默变绿。

packages/spec/src/data/object-strictness-batch20.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,68 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak
252252
expect(msg).toContain('retention');
253253
});
254254

255+
// #6631 — `lifecycle.storage.maxAge` is the third sideways pointer, and the
256+
// mechanism it contrasted was true on exactly one dialect. Physical rotation
257+
// is SQLite-only: `driver-sql` gates it behind
258+
// `get supportsRotation() { return this.isSqlite }` and `rotateShards` throws
259+
// on every other dialect (`packages/drivers/driver-sql/src/sql-driver.ts`).
260+
// The LifecycleService then takes its `'rotation-fallback'` leg —
261+
// `reap(…, 'rotation-fallback', 'created_at', windowMs, …)` in
262+
// `packages/objectql/src/lifecycle/lifecycle-service.ts` — which is a reap BY
263+
// AGE from `created_at`, precisely the mechanism the old guidance told the
264+
// author rotation does not use. What the author most needs to keep believing
265+
// still holds: the retained WINDOW is identical on every dialect. Only the
266+
// reclamation is not — which is what the driver's own note directly above
267+
// `supportsRotation` already says, and what these two surfaces now say too.
268+
//
269+
// ⛔ Scope: the qualifier, not the wording. Matched by idiom — does the text
270+
// name the split at all? — so a rewrite is free and dropping the caveat is
271+
// not. Nothing here asserts anything about what `LifecycleSchema` ACCEPTS,
272+
// which is unchanged (the `onlyWhen`-vs-rotation `superRefine` carries the
273+
// same dialect-specific reason and is deliberately left alone).
274+
it('`lifecycle.storage.maxAge` points SIDEWAYS at `retention` — and qualifies the mechanism it contrasts, which is SQLite-only (#6631)', () => {
275+
const rotating = { strategy: 'rotation', shards: 7, unit: 'day' } as const;
276+
const msg = rejectOnObject({ lifecycle: { class: 'telemetry', storage: { ...rotating, maxAge: '7d' } } });
277+
278+
// Anti-vacuity. `strictObject` rejects an unknown key with or WITHOUT a
279+
// `guidance` entry, so every assertion below would pass on a deleted entry.
280+
// Anchor on the routing advice — the half of this message that was correct
281+
// all along and must survive any rewording of the other half.
282+
expect(msg, 'the `maxAge` guidance bullet is not being produced at all').toContain(
283+
'Set the window with `shards`/`unit`, or use `retention` instead of rotation.',
284+
);
285+
// …and it really is THIS key's guidance rather than something the surface
286+
// says to every unknown key: a sibling unknown key gets the bare rejection.
287+
expect(rejectOnObject({ lifecycle: { class: 'telemetry', storage: { ...rotating, notAStorageKey: 1 } } }))
288+
.not.toMatch(/SQLite/i);
289+
290+
// The pointed-at spelling really does parse (finding 18 — a prescription
291+
// that does not work is worse than none).
292+
accept(ObjectSchema, { ...OBJ, lifecycle: { class: 'telemetry', storage: rotating, retention: { maxAge: '7d' } } });
293+
294+
// The claim under test: both legs of the split are named.
295+
expect(msg, 'the shard DROP must be attributed to SQLite').toMatch(/SQLite/);
296+
expect(msg, 'and the other dialects must be told what they get instead').toMatch(/age-based reap|reaps? [^.]{0,32}by age/i);
297+
298+
// The retired unconditional claim, pinned by name so it cannot come back
299+
// as a paraphrase of the same idea — it is false on Postgres/MySQL, where
300+
// the fallback leg reaps from `created_at` exactly by age.
301+
expect(msg).not.toContain('does not reap by age');
302+
});
303+
304+
it("`lifecycle.storage.strategy`'s description carries the same qualifier — `(O(1) reclaim)` is a property of SQLite, not of the strategy (#6631)", () => {
305+
const doc = LifecycleSchema.shape.storage.unwrap().shape.strategy.description ?? '';
306+
307+
// Anti-vacuity: an empty description satisfies every negative assertion
308+
// below, and is exactly how a pin like this rots into decoration.
309+
expect(doc.length, '`storage.strategy` lost its `.describe()`').toBeGreaterThan(0);
310+
expect(doc, 'the description no longer describes the rotation mechanism at all').toMatch(/shard/i);
311+
312+
expect(doc, 'the O(1) shard DROP must be attributed to SQLite').toMatch(/SQLite/);
313+
expect(doc, 'and the other dialects must be told what they get instead').toMatch(/age-based|by age/i);
314+
expect(doc).not.toContain('rotate by DROPping the oldest shard (O(1) reclaim)');
315+
});
316+
255317
it('`access.sharingModel` points UP — it is a real TOP-LEVEL key, so distance would never find it', () => {
256318
const msg = rejectOnObject({ access: { default: 'private', sharingModel: 'private' } });
257319
expect(msg).toContain('TOP-LEVEL');

packages/spec/src/data/object.zod.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,8 @@ export type ObjectRequiredPermissions = z.input<typeof ObjectRequiredPermissions
600600
* Declares how long an object's data lives and how its space is reclaimed —
601601
* the axis validation/permissions never covered. Enforced at runtime by the
602602
* platform-owned LifecycleService (`@objectstack/objectql`): Reaper (TTL/age
603-
* batch delete), Rotator (time-shard + DROP oldest), Archiver (cold-store
603+
* batch delete), Rotator (time-shard + DROP oldest on SQLite, an age-based
604+
* reap of the same window elsewhere), Archiver (cold-store
604605
* copy then delete). A declared policy with no runtime consumer is a spec
605606
* defect (ADR-0049 enforce-or-remove); the liveness gate requires every
606607
* non-`record` class to declare `retention`, `ttl`, or rotation `storage`.
@@ -732,12 +733,17 @@ export const LifecycleSchema = lazySchema(() => strictObject({
732733
aliases: { count: 'shards', interval: 'unit', period: 'unit', granularity: 'unit' },
733734
guidance: {
734735
maxAge:
735-
'`maxAge` is a `retention` key. Rotation does not reap by age — it retains ' +
736-
'`shards` × `unit` of history and DROPs the oldest shard whole. Set the window ' +
736+
'`maxAge` is a `retention` key. Rotation takes its window from `shards` × `unit`, ' +
737+
'not from an age you name — reclaimed by DROPping the oldest shard whole on SQLite, ' +
738+
'by an equivalent age-based reap elsewhere. Set the window ' +
737739
'with `shards`/`unit`, or use `retention` instead of rotation.',
738740
},
739741
}, {
740-
strategy: z.literal('rotation').describe('Time-shard the table; rotate by DROPping the oldest shard (O(1) reclaim).'),
742+
strategy: z.literal('rotation').describe(
743+
'Time-shard the table. The retained window (`shards` × `unit`) is the same on every ' +
744+
'dialect; the reclamation is not — SQLite DROPs the oldest shard whole (O(1) reclaim), ' +
745+
'other dialects reap that same window by age from `created_at`.',
746+
),
741747
shards: z.number().int().min(2).describe('Number of shards retained; total window = shards × unit.'),
742748
unit: z.enum(['day', 'week', 'month']).describe('Time width of one shard.'),
743749
}).optional().describe('Physical storage strategy for high-frequency telemetry (LifecycleService Rotator).'),

packages/spec/test-typecheck-debt.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"src/data/driver.test.ts": 11,
3434
"src/data/driver/memory.test.ts": 1,
3535
"src/data/field.test.ts": 2,
36-
"src/data/object-strictness-batch20.test.ts": 2,
36+
"src/data/object-strictness-batch20.test.ts": 1,
3737
"src/data/query.test.ts": 25,
3838
"src/identity/scim.test.ts": 7,
3939
"src/integration/connector.test.ts": 7,

0 commit comments

Comments
 (0)