Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/lifecycle-rotation-dialect-caveat.md
Original file line numberDiff line numberDiff line change
@@ -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 静默变绿。
62 changes: 62 additions & 0 deletions packages/spec/src/data/object-strictness-batch20.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand Down
14 changes: 10 additions & 4 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,7 +600,8 @@ export type ObjectRequiredPermissions = z.input<typeof ObjectRequiredPermissions
* Declares how long an object's data lives and how its space is reclaimed —
* the axis validation/permissions never covered. Enforced at runtime by the
* platform-owned LifecycleService (`@objectstack/objectql`): Reaper (TTL/age
* batch delete), Rotator (time-shard + DROP oldest), Archiver (cold-store
* batch delete), Rotator (time-shard + DROP oldest on SQLite, an age-based
* reap of the same window elsewhere), Archiver (cold-store
* copy then delete). A declared policy with no runtime consumer is a spec
* defect (ADR-0049 enforce-or-remove); the liveness gate requires every
* non-`record` class to declare `retention`, `ttl`, or rotation `storage`.
Expand DownExpand Up@@ -732,12 +733,17 @@ export const LifecycleSchema = lazySchema(() => 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).'),
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/test-typecheck-debt.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Loading