Skip to content

Commit 03ca815

Browse files
committed
test(dogfood): prove field-file collection end to end on a real boot (#3459, #3438)
The released-file collection path is the only code on the platform that deletes a user's bytes, and its only proof was unit tests over fakes. Its sibling lineage — attachments — has had an end-to-end proof since #2755; field files, which reach collection by a different route (exclusive ownership, released by the write path, gated per deployment), had none. Five properties, on a real kernel with a real storage adapter, real bytes on disk and a real lifecycle.sweep(): PRECONDITION a store created by this boot has already attested its file migration — so the gate is open and the rest cannot pass vacuously. Doubles as a live check of #3438's creation-time attestation. CYCLE release tombstones; a fresh tombstone survives a sweep; past the declared window the row AND the bytes are reclaimed. REVIVAL re-referencing inside the window brings the file back. VETO (owner) a tombstone that regained an owner behind the hooks' back is un-tombstoned, never reaped — the half of PR-5b that had to ship with the tombstone itself. VETO (gate) a regressed migration flag stops collection immediately, tombstones included, without a restart; re-verifying makes the same row collectable again. This is what the guard's fresh sweep-time flag read buys, and nothing else covered it. The fixture boots PlatformObjectsPlugin explicitly: sys_migration became platform infrastructure rather than a storage concern in #4243, and the gate cannot be read without it. Adds the matching workspace dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KM46HedAWLbkuZyMBXn5wJ
1 parent 3ec8186 commit 03ca815

4 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
test(dogfood): field-file collection proven end to end on a real boot — ADR-0104 PR-5b's cycle, revival, and both veto paths, plus a live check that a store created by the boot has already attested its file migration (#3459, #3438). Test-only, releases nothing.

packages/qa/dogfood/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@objectstack/plugin-security": "workspace:*",
2222
"@objectstack/plugin-sharing": "workspace:*",
2323
"@objectstack/plugin-webhooks": "workspace:*",
24+
"@objectstack/platform-objects": "workspace:*",
2425
"@objectstack/service-analytics": "workspace:*",
2526
"@objectstack/service-messaging": "workspace:*",
2627
"@objectstack/service-storage": "workspace:*",
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// FIELD-FILE COLLECTION — ADR-0104 D3 wave 2 PR-5b (#3459), driven end to end
4+
// through a REAL boot: real storage adapter, real bytes on disk, real
5+
// `lifecycle.sweep()`.
6+
//
7+
// @proof: adr0104-field-file-collection
8+
//
9+
// This is the only code on the platform that deletes a user's bytes, and until
10+
// now the only proof it behaved was unit tests over fakes. Its sibling lineage
11+
// — attachments — has had an end-to-end proof since #2755
12+
// (attachments-permission-matrix.dogfood.test.ts); field files, which reach
13+
// collection by a different route (exclusive ownership, released by the write
14+
// path, gated per deployment), had none.
15+
//
16+
// The four properties, each the inverse of a way this could destroy data:
17+
//
18+
// • CYCLE — a file released by its ONE owning record is tombstoned, and the
19+
// sweep reclaims the row AND the bytes once the declared window passes.
20+
// • REVIVAL — re-referencing inside the window brings it back; the grace
21+
// window is a real window, not a formality.
22+
// • VETO (ownership) — a tombstone that regained an owner behind the hooks'
23+
// back is un-tombstoned, never reaped. This is the half of PR-5b that had
24+
// to ship in the same change as the tombstone itself.
25+
// • VETO (gate) — a deployment whose migration flag has REGRESSED stops
26+
// collecting immediately, without a restart, because the guard re-reads
27+
// the flag at sweep time rather than trusting the memoized read the
28+
// release path uses.
29+
//
30+
// The gate being OPEN here is itself an assertion: this store is created from
31+
// empty by the boot, so #3438's creation-time attestation must have closed the
32+
// gate before any of this can run. If that regressed, the first test says so.
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { promises as fs } from 'node:fs';
36+
import { mkdtempSync } from 'node:fs';
37+
import { join } from 'node:path';
38+
import { tmpdir } from 'node:os';
39+
import { bootStack, type VerifyStack } from '@objectstack/verify';
40+
import { StorageServicePlugin } from '@objectstack/service-storage';
41+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
42+
import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js';
43+
44+
const SYS = { isSystem: true } as const;
45+
const DAY_MS = 86_400_000;
46+
/** Past the `sys_file` tombstone TTL declared in system-file.object.ts. */
47+
const EXPIRED = () => new Date(Date.now() - 31 * DAY_MS);
48+
49+
/** The probe object: one record, one file field, nothing else. */
50+
const PROBE = 'ffc_doc';
51+
52+
describe('objectstack verify FIELD-FILE COLLECTION (ADR-0104 PR-5b): released files are collected, and every way of not knowing keeps them (#adr0104-field-file-collection)', () => {
53+
let stack: VerifyStack;
54+
let rootDir: string;
55+
let ql: any;
56+
let lifecycle: any;
57+
let token: string;
58+
59+
/** Upload real bytes and return the `sys_file` id. Scope `user` — the field
60+
* lineage, deliberately NOT the attachments scope the old guardrail covered. */
61+
async function uploadFile(name: string): Promise<string> {
62+
const auth = { Authorization: `Bearer ${token}` };
63+
const presign = await stack.api('/storage/upload/presigned', {
64+
method: 'POST',
65+
headers: { 'Content-Type': 'application/json', ...auth },
66+
body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: 5, scope: 'user' }),
67+
});
68+
expect(presign.status, 'presign').toBe(200);
69+
const { data } = (await presign.json()) as any;
70+
const put = await stack.raw(String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, ''), {
71+
method: 'PUT',
72+
headers: data.headers ?? { 'content-type': 'text/plain' },
73+
body: 'hello',
74+
});
75+
expect(put.status, 'raw PUT').toBeLessThan(300);
76+
const complete = await stack.api('/storage/upload/complete', {
77+
method: 'POST',
78+
headers: { 'Content-Type': 'application/json', ...auth },
79+
body: JSON.stringify({ fileId: data.fileId }),
80+
});
81+
expect(complete.status, 'complete').toBe(200);
82+
expect(data.fileId, 'presign carried no fileId').toBeTruthy();
83+
return String(data.fileId);
84+
}
85+
86+
const file = (id: string) => ql.findOne('sys_file', { where: { id }, context: SYS });
87+
const bytesExist = async (key: string) =>
88+
fs.access(join(rootDir, key)).then(() => true, () => false);
89+
90+
/** Create a probe record holding `fileId` in its file field. */
91+
async function recordHolding(fileId: string): Promise<string> {
92+
const row = await ql.insert(PROBE, { name: 'probe', doc: fileId }, { context: { ...SYS } });
93+
return String((row as any).id ?? row);
94+
}
95+
96+
beforeAll(async () => {
97+
rootDir = mkdtempSync(join(tmpdir(), 'ffc-dogfood-'));
98+
stack = await bootStack(attachmentsFixtureStack as never, {
99+
security: attachmentsFixtureSecurity(),
100+
extraPlugins: [
101+
// The `sys_migration` flag ledger is platform infrastructure, not a
102+
// storage concern (#4243) — the gate cannot be read without it.
103+
new PlatformObjectsPlugin(),
104+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
105+
],
106+
});
107+
token = await stack.signIn();
108+
ql = await stack.kernel.getServiceAsync('objectql');
109+
lifecycle = await stack.kernel.getServiceAsync('lifecycle');
110+
expect(lifecycle?.sweep, 'the lifecycle service must be registered').toBeTruthy();
111+
112+
// A record object with a file field, registered against the live boot —
113+
// the field-reference hooks are global, so a runtime object is governed
114+
// exactly like an authored one.
115+
ql.registry.registerObject({
116+
name: PROBE,
117+
label: 'Field-file Probe',
118+
fields: {
119+
name: { type: 'text', label: 'Name' },
120+
doc: { type: 'file', label: 'Doc' },
121+
},
122+
});
123+
await ql.syncObjectSchema(PROBE);
124+
}, 120_000);
125+
126+
afterAll(async () => {
127+
await stack?.stop();
128+
});
129+
130+
/**
131+
* The precondition every other test here rests on — and a live proof of
132+
* #3438's creation-time attestation: this store was created from empty by
133+
* this boot, so the gate must already be closed. A deployment that has NOT
134+
* verified its migration collects nothing, which would make the rest of this
135+
* file pass vacuously.
136+
*/
137+
it('PRECONDITION: a store created by this boot has already attested its file migration', async () => {
138+
const flag = await ql.findOne('sys_migration', {
139+
where: { id: 'adr-0104-file-references' },
140+
context: SYS,
141+
});
142+
expect(flag, 'no sys_migration row — creation-time attestation (#3438) regressed').toBeTruthy();
143+
expect(flag.verified_at, 'attested row is not verified').toBeTruthy();
144+
expect(Number(flag.blocking)).toBe(0);
145+
});
146+
147+
it('CYCLE: a released field file is tombstoned, then the sweep reclaims the row and the bytes', async () => {
148+
const fileId = await uploadFile('cycle.txt');
149+
const recordId = await recordHolding(fileId);
150+
151+
// Claimed by exactly one slot.
152+
const claimed = await file(fileId);
153+
expect(claimed.status).toBe('committed');
154+
expect(claimed.ref_object).toBe(PROBE);
155+
expect(String(claimed.ref_id)).toBe(recordId);
156+
expect(claimed.ref_field).toBe('doc');
157+
const key = String(claimed.key);
158+
expect(await bytesExist(key), 'bytes exist before release').toBe(true);
159+
160+
// The owner lets go — an OBSERVED transition, which is the only thing
161+
// that may ever make a file collectable.
162+
await ql.update(PROBE, { id: recordId, doc: null }, { context: { ...SYS } });
163+
const released = await file(fileId);
164+
expect(released.status, 'release must tombstone on a verified deployment').toBe('deleted');
165+
expect(released.deleted_at).toBeTruthy();
166+
expect(released.ref_id ?? null, 'ownership is cleared on release').toBeNull();
167+
168+
// Inside the window the sweep must NOT touch it.
169+
let report = await lifecycle.sweep();
170+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
171+
expect((await file(fileId))?.status, 'a fresh tombstone survives the sweep').toBe('deleted');
172+
expect(await bytesExist(key)).toBe(true);
173+
174+
// Past the window: row gone, bytes gone.
175+
await ql.update('sys_file', { id: fileId, deleted_at: EXPIRED() }, { context: { ...SYS } });
176+
report = await lifecycle.sweep();
177+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
178+
expect(await file(fileId), 'expired tombstone reaped').toBeNull();
179+
expect(await bytesExist(key), 'bytes reclaimed by the guard').toBe(false);
180+
}, 60_000);
181+
182+
it('REVIVAL: re-referencing inside the grace window brings a released file back', async () => {
183+
const fileId = await uploadFile('revive.txt');
184+
const recordId = await recordHolding(fileId);
185+
await ql.update(PROBE, { id: recordId, doc: null }, { context: { ...SYS } });
186+
expect((await file(fileId)).status).toBe('deleted');
187+
188+
// The same record points at it again — a normal write, not a repair.
189+
await ql.update(PROBE, { id: recordId, doc: fileId }, { context: { ...SYS } });
190+
191+
const revived = await file(fileId);
192+
expect(revived.status, 'a re-referenced file comes back to life').toBe('committed');
193+
expect(revived.deleted_at ?? null).toBeNull();
194+
expect(revived.ref_object).toBe(PROBE);
195+
expect(String(revived.ref_id)).toBe(recordId);
196+
197+
// And it survives an expiry-driven sweep, because it is no longer a tombstone.
198+
const report = await lifecycle.sweep();
199+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
200+
expect((await file(fileId))?.status).toBe('committed');
201+
}, 60_000);
202+
203+
/**
204+
* The half of PR-5b that had to ship in the same change as the tombstone.
205+
* Tombstoning released files while the guard re-verified only
206+
* `sys_attachment` — always empty for a field file — would have made every
207+
* release a GUARANTEED byte delete rather than a risky one.
208+
*/
209+
it('VETO (ownership): a tombstone that regained an owner behind the hooks is un-tombstoned, not reaped', async () => {
210+
const fileId = await uploadFile('undead.txt');
211+
const recordId = await recordHolding(fileId);
212+
await ql.update(PROBE, { id: recordId, doc: null }, { context: { ...SYS } });
213+
const tombstoned = await file(fileId);
214+
expect(tombstoned.status).toBe('deleted');
215+
const key = String(tombstoned.key);
216+
217+
// Hook-bypass re-claim: a DIRECT DRIVER write, so no after-hook can
218+
// un-tombstone it. Only the guard's own sweep-time re-verify can catch
219+
// this — which is exactly the property under test.
220+
const driver = ql.getDriverForObject('sys_file');
221+
await driver.update('sys_file', fileId, {
222+
ref_object: PROBE,
223+
ref_id: recordId,
224+
ref_field: 'doc',
225+
});
226+
await ql.update('sys_file', { id: fileId, deleted_at: EXPIRED() }, { context: { ...SYS } });
227+
228+
const report = await lifecycle.sweep();
229+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
230+
231+
const survived = await file(fileId);
232+
expect(survived, 'an owned file must never be reaped').toBeTruthy();
233+
expect(survived.status, 'the guard un-tombstones what regained an owner').toBe('committed');
234+
expect(survived.deleted_at ?? null).toBeNull();
235+
expect(await bytesExist(key), 'bytes untouched').toBe(true);
236+
}, 60_000);
237+
238+
/**
239+
* A deployment whose data has drifted closes its own gate — and must do so
240+
* for tombstones ALREADY written, without waiting for a restart. That is why
241+
* the guard re-reads the flag at sweep time instead of trusting the release
242+
* path's memoized read.
243+
*/
244+
it('VETO (gate): a regressed migration flag stops collection immediately, tombstones and all', async () => {
245+
const fileId = await uploadFile('gated.txt');
246+
const recordId = await recordHolding(fileId);
247+
await ql.update(PROBE, { id: recordId, doc: null }, { context: { ...SYS } });
248+
expect((await file(fileId)).status).toBe('deleted');
249+
const key = String(file ? String((await file(fileId)).key) : '');
250+
await ql.update('sys_file', { id: fileId, deleted_at: EXPIRED() }, { context: { ...SYS } });
251+
252+
// What a later FAILING `os migrate files-to-references --apply` records.
253+
await ql.update(
254+
'sys_migration',
255+
{ id: 'adr-0104-file-references', verified_at: null, blocking: 3 },
256+
{ context: { ...SYS } },
257+
);
258+
try {
259+
const report = await lifecycle.sweep();
260+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
261+
262+
const kept = await file(fileId);
263+
expect(kept, 'a closed gate must keep an expired tombstone').toBeTruthy();
264+
expect(kept.status, 'the release stands; only permission to delete is withheld').toBe('deleted');
265+
expect(await bytesExist(key), 'bytes kept while the gate is closed').toBe(true);
266+
} finally {
267+
await ql.update(
268+
'sys_migration',
269+
{ id: 'adr-0104-file-references', verified_at: new Date(), blocking: 0 },
270+
{ context: { ...SYS } },
271+
);
272+
ql.invalidateDataMigrationFlags?.();
273+
}
274+
275+
// Re-verified: the same row is now collectable, proving the veto was the
276+
// gate and nothing else.
277+
const report = await lifecycle.sweep();
278+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
279+
expect(await file(fileId), 'reaped once the gate is open again').toBeNull();
280+
expect(await bytesExist(key)).toBe(false);
281+
}, 60_000);
282+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)