Skip to content

Commit 7953832

Browse files
os-zhuangclaude
andauthored
feat(lifecycle): ADR-0057 P1–P4 — lifecycle contract, Reaper/Rotator/Archiver, datasource separation, governance (#2786) (#2791)
* docs(adr): ADR-0057 header/rollout sync — P0 shipped (#2079), P1–P4 tracked in #2786 The ADR header still said 'Proposed' with no pointer to the shipped P0 implementation or the new P1–P4 tracking issue. Per repo convention (cf. ADR-0030/0055/0069 headers), reflect implementation reality: - Status: Accepted — P0 shipped with this ADR (#2079); P1–P4 proposed, tracked in #2786 - §4 Rollout: link #2079 on the P0 row; add the #2786 tracking pointer Doc-only change; no code or policy content altered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * feat(lifecycle): ADR-0057 P1 — lifecycle contract, LifecycleService Reaper, liveness gate, dogfood growth gate (#2786) The platform can declare an object's structure, validations and permissions — but not how long its data lives. P1 makes data lifecycle a first-class, declarable, runtime-enforced metadata concern: spec: - `lifecycle` object property (LifecycleSchema): class (record | audit | telemetry | transient | event) + retention.maxAge / ttl / storage(rotation) / archive / reclaim, with duration literals ('6h', '14d', '7y') - §3.5 enforce-or-remove invariants in a superRefine: a non-record class with no bounding policy is rejected; record-class policies are rejected; the archive window must start where the hot window ends - IDataDriver.reclaimSpace?() contract for post-sweep space reclamation objectql: - LifecycleService (Reaper), registered by ObjectQLPlugin as a platform service (default-on, OS_LIFECYCLE_DISABLED=1 / lifecycle.enabled=false to opt out): hourly sweep deletes rows past retention.maxAge (created_at) and past ttl.field + expireAfter, bounds rotation-declared telemetry by shards×unit until the P2 Rotator lands, and never hot-deletes an archive-declared audit ledger before the Archiver copies it out - Sweeps run bulk multi-deletes under a system context — at most ONE afterDelete hook per object per sweep, and telemetry sys_* objects are in the audit writer's SKIP_OBJECTS, so cleanup never re-feeds the tables it drains (the ADR-0052 self-audit trap) driver-sql: - reclaimSpace(): SQLite `PRAGMA incremental_vacuum` (the P0 ADR text claimed this hook shipped; it had not — this closes it), no-op on Postgres/MySQL platform objects (11 annotated): sys_activity 14d+rotation, sys_audit_log 90d→archive 7y, sys_metadata_audit 365d→archive, sys_job_run/sys_automation_run/sys_http_delivery 30d, notification pipeline (sys_notification / delivery / receipt / inbox) one 90d window, sys_device_code expires_at+1d. gates: - spec-liveness: object/lifecycle classified + bound to a dogfood proof (HIGH_RISK_CLASSES 'data-lifecycle') - dogfood storage-growth gate (@proof: adr0057-lifecycle-bounded-growth): boots showcase, proves contract coverage, reaps a backdated runaway writer, asserts record-class data untouched and archive-declared ledgers retained Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * feat(lifecycle): ADR-0057 P2 — Rotator: physical time-sharding with O(1) shard DROP (#2786) High-frequency telemetry declared with lifecycle.storage.strategy='rotation' is now physically time-sharded on SQLite (the ServiceNow Table Rotation model, ADR-0057 §3.3): driver-sql: - rotateShards(objectDef, nowMs): idempotent Rotator entry — ensures the current shard (<table>__r<key>: day/week/month UTC keys), adopts a legacy pre-rotation table as its first shard (no data loss), column-syncs every retained shard so the UNION stays uniform, DROPs shards past the shards×unit time window (O(1) reclaim), and rebuilds the read view - The base name becomes a READ-ONLY UNION ALL view (SQLite views reject writes and RETURNING), so every write path redirects shard-wise instead: create/bulkCreate/upsert target the current shard; by-id update/delete probe shards newest-first; updateMany/deleteMany/bulkDelete fan out and sum — reads (find/count/aggregate) are untouched - Per-shard bookkeeping aliases (datetime/date/json/boolean coercion, tenant scope, timestamp stamping) so a shard builder behaves exactly like the view - initObjects routes rotation-declared objects to the Rotator (the plain create/alter DDL path would collide with the view) - supportsRotation gates on the dialect; declared indexes get per-shard names objectql LifecycleService: - Rotator invocation each sweep when the driver supports it; the fallback age-based reap covers every other dialect so the declared bound holds everywhere, only the reclamation mechanics differ - An explicit retention.maxAge still trims inside live shards after rotation (shard drops are unit-granular; this also immediately bounds a legacy table the Rotator just adopted whole) - Dropped shards trigger the datasource incremental-vacuum pass Transient TTL expiry (the other P2 line item) shipped with the P1 Reaper — TTL and age reaping are one mechanism (§3.3). Proof: sql-driver-rotation.test.ts drives the full shard lifecycle (create → rotate → window drop → adoption → cross-shard writes); the dogfood storage-growth gate now shards a probe stream through the real engine→driver path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * feat(lifecycle): ADR-0057 P3 — datasource separation + Archiver cold store (#2786) Separation (§3.6): objects whose lifecycle.class is telemetry / event / audit now route to a dedicated 'telemetry' datasource whenever one is registered — engine getDriver() step 3, between datasourceMapping rules and the package-manifest defaultDatasource. Opt-in purely by the datasource's existence: no 'telemetry' driver ⇒ resolution is byte-for-byte what it was. Even on SQLite that is a separate file, so platform-generated growth can never again pollute the business DB. 'transient' deliberately stays on the primary: those objects are user-session data and some (better-auth's sys_device_code) are accessed outside the engine — splitting their storage would split their brain. Archiver (§3.3): audit-class objects with 'archive' declared now get the full retain → archive → delete flow instead of the P1 retain-only skip: - copies rows past archive.after to the archive datasource in bounded batches (500 × 20/sweep — a backlog drains across sweeps), as per-row idempotent upserts so an interrupted sweep re-converges - hot-deletes exactly the copied ids (bulkDelete), then the reclaim pass vacuums the hot store - archive.keep prunes the cold store itself - the cold schema is mirrored via idempotent syncSchema - no archive datasource registered ⇒ rows retained, object reported 'archive-pending' — a compliance ledger is never dropped unarchived Proof: engine-lifecycle-datasource.test.ts (routing incl. explicit-datasource precedence), Archiver unit tests (copy/delete id-exactness, keep-pruning, missing-datasource retention), and a dogfood case that provisions a real second SQL store mid-run and watches the ledger drain hot → cold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * feat(lifecycle): ADR-0057 P4 — governance: quotas, growth alerts, tenant retention overrides (#2786) Adds the operations layer over the P1-P3 enforcement machinery, resolved from a new 'lifecycle' settings namespace (registered by ObjectQLPlugin at kernel:ready when a SettingsService is present; absent settings = declared policies apply unmodified): - enabled — runtime master switch (on top of OS_LIFECYCLE_DISABLED) - retention_overrides — per-object window overrides { object: { maxAge, expireAfter } }. TENANT-scoped: the sweep enumerates organizations, and a tenant whose override is genuinely tenant-scoped (not inherited) gets its own cutoff on its own rows, while the global pass covers everyone else INCLUDING NULL-org rows (a bare $nin would silently skip them). This is ADR-0057 §3.2 verbatim: regulated tenants set years, dev sets days — the same knob at different settings. An unparseable override keeps the declared window (never fails open into unbounded growth). - quotas / quota_defaults — per-object and per-class row ceilings - growth_alert_rows — per-sweep growth spike threshold Quota and growth breaches are OBSERVE-AND-ALERT only (report.alerts + onAlert sink, default logger warning) — governance never deletes beyond the declared policy; an operator decides. Every settings read is best-effort and snapshotted once per sweep; the tenant scan is capped (200) and skipped entirely on single-tenant kernels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * docs(adr)+chore: ADR-0057 marked P0–P4 implemented; changeset for the lifecycle release (#2786) - ADR-0057 header + §4 rollout table now record P1–P4 as implemented in #2791; §3.3 gains the archive-safety hard rule and §3.6 the as-implemented 'telemetry' datasource routing note (transient exclusion rationale). - Changeset: minor bumps across spec / objectql / driver-sql / driver-sqlite-wasm / platform-objects / metadata-core / service-messaging / service-automation / plugin-audit, documenting the bounded-by-default behavior change and the override knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * chore(spec): regenerate api-surface snapshot for the ADR-0057 lifecycle exports The 5 additions (LifecycleSchema / LifecycleClassSchema / Lifecycle / LifecycleClass / LIFECYCLE_DURATION_REGEX) are the intentional P1 spec surface; the api-surface gate flagged them as uncommitted snapshot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c19139 commit 7953832

31 files changed

Lines changed: 2605 additions & 28 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/driver-sql': minor
5+
'@objectstack/driver-sqlite-wasm': minor
6+
'@objectstack/platform-objects': minor
7+
'@objectstack/metadata-core': minor
8+
'@objectstack/service-messaging': minor
9+
'@objectstack/service-automation': minor
10+
'@objectstack/plugin-audit': minor
11+
---
12+
13+
ADR-0057 data lifecycle P1–P4 (#2786): platform-generated data is now bounded by construction.
14+
15+
- **P1 — contract**: new `lifecycle` object property (`class: record | audit | telemetry | transient | event` + `retention` / `ttl` / `storage(rotation)` / `archive` / `reclaim`), enforced by the platform-owned **LifecycleService** registered by `ObjectQLPlugin` (default-on; disable via `OS_LIFECYCLE_DISABLED=1` or plugin `lifecycle.enabled=false`). The Reaper batch-deletes rows past `retention.maxAge` / `ttl` under a system context and reclaims space (`SqlDriver.reclaimSpace()` → SQLite `PRAGMA incremental_vacuum`). Non-`record` classes must declare a bounding policy (parse-time invariant + spec-liveness gate + dogfood storage-growth gate).
16+
- **P2 — rotation**: `storage: { strategy: 'rotation', shards, unit }` physically time-shards the table on SQLite — writes land in the current shard, reads go through a UNION-ALL view under the base name, expiry is an O(1) `DROP` of shards past the window. A legacy table is adopted as the first shard on upgrade. Other dialects fall back to an equivalent age-based reap.
17+
- **P3 — separation + Archiver**: registering a datasource named `telemetry` routes telemetry/event/audit objects to it (opt-in by existence; `transient` deliberately stays on the primary). Audit objects with `archive` declared get retain → archive → delete once the archive datasource exists; without it rows are retained, never dropped unarchived.
18+
- **P4 — governance**: new `lifecycle` settings namespace — runtime enable switch, per-object retention overrides (tenant-scoped: regulated tenants set years, dev sets days), per-object/per-class row quotas and growth alerts (observe-and-alert only).
19+
20+
**Behavior change**: 11 platform objects now carry lifecycle declarations and their telemetry is bounded by default — `sys_activity` 14d (rotated), `sys_audit_log` 90d hot → archive (retained forever until an `archive` datasource is registered), `sys_metadata_audit` 365d → archive, `sys_job_run` / `sys_automation_run` / `sys_http_delivery` 30d, notification pipeline (`sys_notification`, delivery, receipt, inbox) 90d, `sys_device_code` expires_at + 1d. Extend windows per environment/tenant via the `lifecycle.retention_overrides` setting.

docs/adr/0057-system-data-lifecycle-and-retention.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0057: System data has a lifecycle — declarative retention, rotation, and reclamation for platform-generated objects
22

3-
**Status**: Proposed (2026-06-20)
3+
**Status**: Accepted — **P0–P4 implemented** (P0 shipped with this ADR, [#2079](https://github.com/objectstack-ai/framework/pull/2079); P1–P4 in [#2791](https://github.com/objectstack-ai/framework/pull/2791), tracked in [#2786](https://github.com/objectstack-ai/framework/issues/2786)) (proposed 2026-06-20 · implemented 2026-07-10)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0052](./0052-audit-is-not-the-activity-feed.md) (decomposed audit / activity / collaboration into bounded contexts — this ADR adds the *orthogonal* axis those contexts never specified: **how long each one lives and how its space is reclaimed**), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared `retention` that drives no sweeper is dead surface; this ADR wires it to a runtime consumer), [ADR-0030](./0030-notification-platform-convergence.md) (notification objects are messaging-owned with their own lifecycle), [ADR-0021](./0021-analytics-dataset-semantic-layer.md) (precedent for moving event-shaped data off the primary OLTP store)
66
**Consumers**: `@objectstack/spec` (`lifecycle` object property + `LifecycleClass`), `@objectstack/objectql` (LifecycleService: Reaper / Rotator / Archiver), `@objectstack/plugin-audit` (audit/activity exclusion of telemetry objects — shipped P0), `@objectstack/driver-sql` (`auto_vacuum=INCREMENTAL` default + incremental-vacuum hook — shipped P0), `@objectstack/dogfood` (storage-growth regression gate), platform spec-liveness gate
@@ -164,6 +164,12 @@ activity-logged** (otherwise cleanup re-feeds the tables it is draining — the
164164
same self-audit trap ADR-0052 already guards). Aggregate one summary row at
165165
most.
166166

167+
Second hard rule (implementation): **an object that declares `archive` is
168+
never hot-deleted before the archive copy succeeded.** No archive datasource
169+
registered ⇒ rows are retained (today's behavior), reported as
170+
`archive-pending` — a compliance ledger cannot be destroyed by declaring a
171+
lifecycle.
172+
167173
### 3.4 Reclaim — driver space hygiene
168174

169175
SQLite driver defaults to `auto_vacuum=INCREMENTAL` (shipped P0); the Reaper
@@ -192,15 +198,25 @@ the store can later be swapped for an append-only log / time-series / object
192198
store. This is the ADR-0021 dataset-migration pattern applied to event-shaped
193199
system data.
194200

201+
*Implemented as*: registering a datasource named **`telemetry`** routes every
202+
`telemetry`/`event`/`audit`-classed object to it (engine `getDriver` step 3 —
203+
after explicit `datasource`/`datasourceMapping`, before the manifest
204+
`defaultDatasource`). Opt-in purely by the datasource's existence.
205+
`transient` deliberately stays on the primary: those objects are user-session
206+
data and some (better-auth's `sys_device_code`) are accessed outside the
207+
engine — splitting their storage would split their brain.
208+
195209
## 4. Rollout
196210

211+
P1–P4 are tracked in [#2786](https://github.com/objectstack-ai/framework/issues/2786).
212+
197213
| Phase | Scope | Status |
198214
|---|---|---|
199-
| **P0 — stop the bleed** | (a) exclude operational/plumbing objects from the audit+activity writer (`plugin-audit` `SKIP_OBJECTS`); (b) SQLite `auto_vacuum=INCREMENTAL` driver default; (c) showcase digest interval 20s→60s, flagged demo-only | **this ADR ships P0** |
200-
| **P1 — contract** | `lifecycleClass` + `lifecycle` spec; LifecycleService Reaper; spec-liveness enforcement; dogfood growth gate | proposed |
201-
| **P2 — rotation/TTL** | Rotator (shard + DROP) for high-freq telemetry; transient TTL expiry | proposed |
202-
| **P3 — separation** | telemetry/audit on a dedicated datasource; Archiver cold-store | proposed |
203-
| **P4 — governance** | per-table storage quotas, growth alerts, tenant-level retention overrides | proposed |
215+
| **P0 — stop the bleed** | (a) exclude operational/plumbing objects from the audit+activity writer (`plugin-audit` `SKIP_OBJECTS`); (b) SQLite `auto_vacuum=INCREMENTAL` driver default; (c) showcase digest interval 20s→60s, flagged demo-only | **shipped with this ADR ([#2079](https://github.com/objectstack-ai/framework/pull/2079))** |
216+
| **P1 — contract** | `lifecycleClass` + `lifecycle` spec; LifecycleService Reaper; spec-liveness enforcement; dogfood growth gate | **implemented ([#2791](https://github.com/objectstack-ai/framework/pull/2791))** |
217+
| **P2 — rotation/TTL** | Rotator (shard + DROP) for high-freq telemetry; transient TTL expiry | **implemented ([#2791](https://github.com/objectstack-ai/framework/pull/2791))** |
218+
| **P3 — separation** | telemetry/audit on a dedicated datasource; Archiver cold-store | **implemented ([#2791](https://github.com/objectstack-ai/framework/pull/2791))** |
219+
| **P4 — governance** | per-table storage quotas, growth alerts, tenant-level retention overrides | **implemented ([#2791](https://github.com/objectstack-ai/framework/pull/2791))** |
204220

205221
P0 alone removes ~76 % of the row growth (audit/activity exclusion) and a
206222
further 3× (interval), and lets space be reclaimed — without any schema change
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// STORAGE GROWTH — ADR-0057 data lifecycle, exercised end-to-end through a real
4+
// showcase boot (#2786 P1). The 260 MB dev.db regression happened because
5+
// platform-generated rows had NO retention contract: every append-only table
6+
// grew forever. This gate makes that class of regression revert-provable:
7+
//
8+
// @proof: adr0057-lifecycle-bounded-growth
9+
//
10+
// • CONTRACT — every registered object declaring a non-`record` lifecycle
11+
// class carries a bounding policy (retention / ttl / rotation), and the
12+
// shipped sys_* declarations are present in a real boot (deleting one
13+
// turns this red).
14+
// • REAPER — rows older than the declared window are deleted by
15+
// `lifecycle.sweep()`; fresh rows and record-class/business data are
16+
// untouched; space reclaim runs on the touched datasource.
17+
// • ARCHIVE SAFETY — an audit-class object with `archive` declared is NEVER
18+
// hot-deleted before the Archiver has copied it out (no archive
19+
// datasource registered ⇒ rows are retained, not dropped).
20+
//
21+
// The runaway writer is simulated with direct driver inserts (backdated
22+
// `created_at`) rather than waiting on scheduled flow ticks — deterministic,
23+
// and it exercises the exact path the Reaper sweeps.
24+
25+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
26+
import { bootStack, type VerifyStack } from '@objectstack/verify';
27+
import showcaseStack from '@objectstack/example-showcase';
28+
29+
const DAY_MS = 86_400_000;
30+
31+
interface EngineLike {
32+
registry: {
33+
getAllObjects(): Array<{ name: string; lifecycle?: Record<string, any> }>;
34+
registerObject(obj: Record<string, unknown>): void;
35+
getObject(name: string): { name: string; lifecycle?: Record<string, any> } | undefined;
36+
};
37+
syncObjectSchema(name: string): Promise<void>;
38+
getDriverForObject(name: string): DriverLike | undefined;
39+
}
40+
41+
interface DriverLike {
42+
create(object: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
43+
count(object: string, query?: Record<string, unknown>): Promise<number>;
44+
reclaimSpace?(): Promise<void>;
45+
}
46+
47+
interface LifecycleLike {
48+
sweep(): Promise<{
49+
swept: Array<{ object: string; policy: string; deleted?: number }>;
50+
skipped: Array<{ object: string; reason: string }>;
51+
errors: Array<{ object: string; error: string }>;
52+
reclaimed: string[];
53+
}>;
54+
}
55+
56+
describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growth (#adr0057-lifecycle-bounded-growth)', () => {
57+
let stack: VerifyStack;
58+
let engine: EngineLike;
59+
let lifecycle: LifecycleLike;
60+
let driver: DriverLike;
61+
62+
// Platform storage convention: `created_at` is registry-injected as a
63+
// `datetime` field, stored as epoch-ms INTEGER on SQLite (a JS Date through
64+
// the driver) — and filter cutoffs are coerced to match. Backdate with Date
65+
// objects, exactly like the real write paths, or the rows land as TEXT and
66+
// no temporal filter ever matches them.
67+
const backdated = (days: number) => new Date(Date.now() - days * DAY_MS);
68+
69+
beforeAll(async () => {
70+
stack = await bootStack(showcaseStack);
71+
engine = stack.kernel.getService('objectql') as unknown as EngineLike;
72+
lifecycle = stack.kernel.getService('lifecycle') as unknown as LifecycleLike;
73+
expect(lifecycle?.sweep, 'the ObjectQLPlugin must register the ADR-0057 lifecycle service').toBeTruthy();
74+
75+
// Fixture objects: a telemetry stream, a record-class sibling, and an
76+
// audit ledger with a declared (but unresolvable) archive target.
77+
engine.registry.registerObject({
78+
name: 'growth_probe_event',
79+
label: 'Growth Probe Event',
80+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
81+
fields: { payload: { type: 'text', label: 'Payload' } },
82+
});
83+
engine.registry.registerObject({
84+
name: 'growth_probe_record',
85+
label: 'Growth Probe Record',
86+
lifecycle: { class: 'record' },
87+
fields: { payload: { type: 'text', label: 'Payload' } },
88+
});
89+
engine.registry.registerObject({
90+
name: 'growth_probe_ledger',
91+
label: 'Growth Probe Ledger',
92+
lifecycle: {
93+
class: 'audit',
94+
retention: { maxAge: '90d' },
95+
archive: { after: '90d', to: 'archive_missing' },
96+
},
97+
fields: { payload: { type: 'text', label: 'Payload' } },
98+
});
99+
await engine.syncObjectSchema('growth_probe_event');
100+
await engine.syncObjectSchema('growth_probe_record');
101+
await engine.syncObjectSchema('growth_probe_ledger');
102+
103+
driver = engine.getDriverForObject('growth_probe_event') as DriverLike;
104+
expect(driver?.create, 'a driver must back the probe objects').toBeTruthy();
105+
}, 60_000);
106+
107+
afterAll(async () => {
108+
await stack?.stop();
109+
});
110+
111+
it('CONTRACT: every non-record lifecycle declaration in a real boot carries a bounding policy', () => {
112+
const declared = engine.registry
113+
.getAllObjects()
114+
.filter((o) => o.lifecycle && o.lifecycle.class !== 'record');
115+
116+
// A platform boot with ZERO lifecycle-declared objects would mean the
117+
// shipped sys_* annotations were dropped — exactly the regression this
118+
// gate exists to catch.
119+
expect(declared.length, 'no lifecycle-declared objects registered — sys_* annotations were dropped').toBeGreaterThan(0);
120+
121+
for (const obj of declared) {
122+
const lc = obj.lifecycle!;
123+
expect(
124+
Boolean(lc.retention || lc.ttl || lc.storage),
125+
`${obj.name} declares lifecycle.class='${lc.class}' with NO bounding policy (retention/ttl/storage) — ADR-0057 §3.5`,
126+
).toBe(true);
127+
}
128+
});
129+
130+
it('CONTRACT: the shipped sys_metadata_audit declaration survives (audit + archive-then-delete)', () => {
131+
const meta = engine.registry.getObject('sys_metadata_audit');
132+
expect(meta, 'sys_metadata_audit must be registered in a platform boot').toBeTruthy();
133+
expect(meta!.lifecycle?.class).toBe('audit');
134+
expect(meta!.lifecycle?.retention?.maxAge).toBe('365d');
135+
expect(meta!.lifecycle?.archive?.to).toBe('archive');
136+
});
137+
138+
it('REAPER: past-window telemetry is reaped, fresh telemetry and record-class data survive, space is reclaimed', async () => {
139+
// Simulate a runaway writer: 40 telemetry rows spread over ~200 days,
140+
// 10 of them inside the 30d window; plus record-class rows older than
141+
// any window (must never be touched).
142+
for (let i = 0; i < 40; i++) {
143+
await driver.create('growth_probe_event', {
144+
payload: `tick-${i}`,
145+
created_at: backdated(5 + i * 5), // 5d … 200d
146+
});
147+
}
148+
for (let i = 0; i < 5; i++) {
149+
await driver.create('growth_probe_record', {
150+
payload: `business-${i}`,
151+
created_at: backdated(400),
152+
});
153+
}
154+
155+
const before = await driver.count('growth_probe_event', { object: 'growth_probe_event' });
156+
expect(before).toBe(40);
157+
158+
const report = await lifecycle.sweep();
159+
160+
// Errors first — a failed delete otherwise shows up as a confusing
161+
// "expected 40 to be 5" count mismatch.
162+
expect(report.errors, `sweep reported errors: ${JSON.stringify(report.errors)}`).toEqual([]);
163+
expect(
164+
report.swept.map((e) => e.object),
165+
`sweep applied no policy — report: ${JSON.stringify(report)}`,
166+
).toContain('growth_probe_event');
167+
168+
// The telemetry table is now bounded by its declared 30d window:
169+
// rows at 5,10,15,20,25d survive (5 rows), everything older is gone.
170+
const after = await driver.count('growth_probe_event', { object: 'growth_probe_event' });
171+
expect(after, 'telemetry rows past retention.maxAge must be reaped').toBe(5);
172+
173+
// Record-class/business data is sacrosanct — same age, still alive.
174+
const records = await driver.count('growth_probe_record', { object: 'growth_probe_record' });
175+
expect(records, 'record-class rows must NEVER be reaped').toBe(5);
176+
177+
// The sweep reported the reap and reclaimed the datasource.
178+
const entry = report.swept.find((e) => e.object === 'growth_probe_event');
179+
expect(entry?.policy).toBe('retention');
180+
expect(report.errors).toEqual([]);
181+
expect(report.reclaimed.length, 'reclaimSpace must run on the touched datasource').toBeGreaterThan(0);
182+
});
183+
184+
it('ROTATOR (P2): a rotation-declared stream is physically sharded through the real engine → driver path', async () => {
185+
engine.registry.registerObject({
186+
name: 'growth_probe_stream',
187+
label: 'Growth Probe Stream',
188+
lifecycle: {
189+
class: 'telemetry',
190+
storage: { strategy: 'rotation', shards: 3, unit: 'day' },
191+
},
192+
fields: { payload: { type: 'text', label: 'Payload' } },
193+
});
194+
await engine.syncObjectSchema('growth_probe_stream');
195+
196+
// The base name is now a read view; writes land in the current shard.
197+
const streamDriver = engine.getDriverForObject('growth_probe_stream') as DriverLike & {
198+
execute(sql: string): Promise<unknown>;
199+
};
200+
const master = (await streamDriver.execute(
201+
"SELECT name, type FROM sqlite_master WHERE name LIKE 'growth_probe_stream%' AND name NOT LIKE '%autoindex%'",
202+
)) as Array<{ name: string; type: string }>;
203+
const types = Object.fromEntries(master.map((r) => [r.name, r.type]));
204+
expect(types['growth_probe_stream'], 'rotation must turn the base name into a view').toBe('view');
205+
const shardNames = master.filter((r) => /__r\d{6,8}$/.test(r.name)).map((r) => r.name);
206+
expect(shardNames.length, 'a current shard table must exist').toBeGreaterThan(0);
207+
208+
// Round-trip: write through the driver, read through the view, and a
209+
// sweep applies the 'rotation' policy without touching the rows inside
210+
// the window.
211+
await streamDriver.create('growth_probe_stream', { payload: 'tick' });
212+
expect(await streamDriver.count('growth_probe_stream', { object: 'growth_probe_stream' })).toBe(1);
213+
214+
const report = await lifecycle.sweep();
215+
const entry = report.swept.find((e) => e.object === 'growth_probe_stream');
216+
expect(entry?.policy).toBe('rotation');
217+
expect(await streamDriver.count('growth_probe_stream', { object: 'growth_probe_stream' })).toBe(1);
218+
});
219+
220+
it('ARCHIVE SAFETY: an audit ledger with a declared archive is never hot-deleted unarchived', async () => {
221+
for (let i = 0; i < 3; i++) {
222+
await driver.create('growth_probe_ledger', {
223+
payload: `ledger-${i}`,
224+
created_at: backdated(365), // far past the 90d hot window
225+
});
226+
}
227+
228+
const report = await lifecycle.sweep();
229+
230+
// No archive datasource named 'archive_missing' exists ⇒ the rows are
231+
// RETAINED (today's behavior), not dropped. Compliance data cannot be
232+
// destroyed by declaring a lifecycle.
233+
const ledger = await driver.count('growth_probe_ledger', { object: 'growth_probe_ledger' });
234+
expect(ledger, 'archive-declared audit rows must be retained until archived').toBe(3);
235+
expect(report.skipped).toContainEqual({ object: 'growth_probe_ledger', reason: 'archive-pending' });
236+
});
237+
238+
it('ARCHIVER (P3): once the archive datasource exists, cold rows move there and leave the hot store', async () => {
239+
// Provision a real second SQL store under the datasource name the ledger
240+
// declares, then re-run the sweep: retain → archive → delete.
241+
const { SqlDriver } = await import('@objectstack/driver-sql');
242+
const cold = new SqlDriver({
243+
client: 'better-sqlite3',
244+
connection: { filename: ':memory:' },
245+
useNullAsDefault: true,
246+
} as any);
247+
Object.defineProperty(cold, 'name', { value: 'archive_missing' });
248+
await cold.connect();
249+
(engine as unknown as { registerDriver(d: unknown): void }).registerDriver(cold);
250+
251+
const report = await lifecycle.sweep();
252+
253+
const entry = report.swept.find((e) => e.object === 'growth_probe_ledger');
254+
expect(entry?.policy).toBe('archive');
255+
expect((entry as { archived?: number })?.archived).toBe(3);
256+
257+
// Hot store drained, cold store holds the ledger.
258+
const hot = await driver.count('growth_probe_ledger', { object: 'growth_probe_ledger' });
259+
expect(hot, 'archived rows must leave the hot store').toBe(0);
260+
const coldRows = await cold.count('growth_probe_ledger', { object: 'growth_probe_ledger' });
261+
expect(coldRows, 'archived rows must land in the cold store').toBe(3);
262+
await cold.disconnect();
263+
});
264+
});

0 commit comments

Comments
 (0)