Skip to content

Commit 4d1eb11

Browse files
os-zhuangclaude
andcommitted
test(dogfood): full package-lifecycle gate for package-first authoring (ADR-0070 D3/D4/D5)
The existing dogfood gate only proved the D1/D2 backstop (kernel rejects a runtime-only create into a read-only code package with `writable_package_required`). ADR-0070's other guarantee — "the package is the authoring & delete unit" — was only ever live-verified by hand. This promotes that manual dogfood into an automated proof against the real booted stack: - D3/D4 full chain: create -> bind -> publish -> editable -> discoverable (getMetaItems by package) -> delete-cascade (deletePackage empties the base). The "editable" step guards the #2252 read-only-after-publish trap directly. - D5 migration: reassignOrphanedMetadata rebinds a legacy package-less row (the #2252/#1946 stopgap shape: package_id = null) onto a base. - D4 duplicate: duplicatePackage clones ACTIVE items into a new base, re-namespacing object names and rewriting intra-package references. Regressing deletePackage / duplicatePackage / reassignOrphanedMetadata now turns this red, alongside the original D1 backstop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27af33a commit 4d1eb11

1 file changed

Lines changed: 212 additions & 7 deletions

File tree

packages/dogfood/test/package-first-authoring.dogfood.test.ts

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// GOLDEN REGRESSION — ADR-0070 package-first authoring, exercised end-to-end
4-
// through the real booted stack (not a mocked unit). The kernel must REJECT a
5-
// runtime-only create that targets a read-only code/installed package with
6-
// `writable_package_required` (D1/D2) instead of silently coercing it to a
7-
// package-less orphan (the pre-ADR #2252 behavior). This is the contract the
8-
// Studio + AI surfaces rely on as the backstop.
4+
// through the real booted stack (not a mocked unit). Two contracts are gated:
95
//
10-
// Reverting D1 (the writable_package_required throw in saveMetaItem) turns this
11-
// red — that is the point of the gate.
6+
// 1. D1/D2 backstop — the kernel REJECTS a runtime-only create that targets a
7+
// read-only code/installed package with `writable_package_required` instead
8+
// of silently coercing it to a package-less orphan (the pre-ADR #2252 bug).
9+
// This is the contract the Studio + AI surfaces rely on.
10+
//
11+
// 2. The package is the lifecycle unit (D3/D4/D5) — a base is the unit you
12+
// author into, discover by, edit, duplicate, delete (cascade), and adopt
13+
// loose items into. The full chain runs against the live protocol service:
14+
// create -> bind -> publish -> editable -> discoverable -> delete-cascade,
15+
// plus the D4 "duplicate base" and D5 "adopt orphans" migration gestures.
16+
//
17+
// Reverting D1 (the writable_package_required throw in saveMetaItem) turns the
18+
// first block red; regressing the package-lifecycle methods (deletePackage,
19+
// duplicatePackage, reassignOrphanedMetadata) turns the rest red. That is the
20+
// point of the gate — the manual dogfood in the ADR is now an automated proof.
1221

1322
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1423
import crmStack from '@objectstack/example-crm';
1524
import { bootStack, type VerifyStack } from '@objectstack/verify';
1625

26+
// sys_metadata is the durable store every lifecycle method (deletePackage,
27+
// duplicatePackage, reassignOrphanedMetadata) reads from — assertions against it
28+
// are deterministic, unlike registry merges. `name` is what each row keys on.
29+
async function ownedNames(ql: any, packageId: string): Promise<string[]> {
30+
const rows = (await ql.find('sys_metadata', { where: { package_id: packageId }, context: { isSystem: true } })) as any[];
31+
return rows.map((r) => r.name);
32+
}
33+
1734
describe('dogfood: package-first authoring rejects runtime creates into read-only packages (ADR-0070 D1/D2)', () => {
1835
let stack: VerifyStack;
1936

@@ -57,3 +74,191 @@ describe('dogfood: package-first authoring rejects runtime creates into read-onl
5774
expect(res?.success ?? true).toBeTruthy();
5875
});
5976
});
77+
78+
describe('dogfood: the package is the authoring & delete unit (ADR-0070 D3/D4)', () => {
79+
let stack: VerifyStack;
80+
let protocol: any;
81+
let ql: any;
82+
// A fresh, writable, project-scoped base (never a code/installed package).
83+
const BASE = 'app.dogfood_lifecycle';
84+
const OBJ = 'dfl_widget';
85+
86+
beforeAll(async () => {
87+
stack = await bootStack(crmStack);
88+
protocol = await stack.kernel.getServiceAsync<any>('protocol');
89+
ql = await stack.kernel.getServiceAsync<any>('objectql');
90+
});
91+
afterAll(async () => {
92+
await stack?.stop?.();
93+
});
94+
95+
it('create -> bind -> publish -> editable -> discoverable -> delete-cascade', async () => {
96+
// 1. CREATE (draft) bound to the writable base — accepted (not coerced/rejected).
97+
const draft = await protocol.saveMetaItem({
98+
type: 'object',
99+
name: OBJ,
100+
item: { name: OBJ, label: 'Widget', fields: { name: { type: 'text', label: 'Name' } } },
101+
packageId: BASE,
102+
mode: 'draft',
103+
});
104+
expect(draft?.success ?? true).toBeTruthy();
105+
106+
// 2. BIND — the row is owned by the base, never orphaned (package_id is real).
107+
expect(await ownedNames(ql, BASE)).toContain(OBJ);
108+
109+
// 3. PUBLISH — promotes the draft to active (creates the physical table).
110+
const published = await protocol.saveMetaItem({
111+
type: 'object',
112+
name: OBJ,
113+
item: { name: OBJ, label: 'Widget', fields: { name: { type: 'text', label: 'Name' } } },
114+
packageId: BASE,
115+
mode: 'publish',
116+
});
117+
expect(published?.success ?? true).toBeTruthy();
118+
119+
// 4. EDITABLE — a writable base accrues registered objects once it publishes,
120+
// yet stays writable (the #2252 read-only-after-publish trap is designed
121+
// out): a re-publish edit must NOT throw writable_package_required.
122+
const edit = await protocol.saveMetaItem({
123+
type: 'object',
124+
name: OBJ,
125+
item: {
126+
name: OBJ,
127+
label: 'Widget (edited)',
128+
fields: { name: { type: 'text', label: 'Name' }, qty: { type: 'number', label: 'Qty' } },
129+
},
130+
packageId: BASE,
131+
mode: 'publish',
132+
});
133+
expect(edit?.success ?? true).toBeTruthy();
134+
135+
// 5. DISCOVERABLE — the package-scoped Studio list (getMetaItems by package)
136+
// surfaces the authored object; it is not lost in a null bucket.
137+
const listed = await protocol.getMetaItems({ type: 'object', packageId: BASE });
138+
const names = (Array.isArray(listed) ? listed : (listed?.items ?? [])).map(
139+
(i: any) => i?.name ?? i?.metadata?.name,
140+
);
141+
expect(names, `getMetaItems(package=${BASE}) should surface ${OBJ}`).toContain(OBJ);
142+
143+
// 6. DELETE-CASCADE — deleting the base removes every item it owns; the base
144+
// becomes empty. This is the answer to "a pile of loose metadata, how do I
145+
// delete it?" — operate on the whole base.
146+
const del = await protocol.deletePackage({ packageId: BASE });
147+
expect(del.deletedCount).toBeGreaterThan(0);
148+
expect(del.failedCount).toBe(0);
149+
expect(await ownedNames(ql, BASE)).not.toContain(OBJ);
150+
});
151+
});
152+
153+
describe('dogfood: adopt orphaned metadata into a base (ADR-0070 D5 migration)', () => {
154+
let stack: VerifyStack;
155+
let protocol: any;
156+
let ql: any;
157+
const BASE = 'app.dogfood_adopt';
158+
const ORPHAN = 'dfa_legacy_orphan';
159+
160+
beforeAll(async () => {
161+
stack = await bootStack(crmStack);
162+
protocol = await stack.kernel.getServiceAsync<any>('protocol');
163+
ql = await stack.kernel.getServiceAsync<any>('objectql');
164+
});
165+
afterAll(async () => {
166+
await stack?.stop?.();
167+
});
168+
169+
it('reassignOrphanedMetadata rebinds a legacy package-less row onto a base', async () => {
170+
// Simulate a pre-package-first stopgap leftover: a runtime-authored item with
171+
// package_id = null (the #2252 coerce-to-null / #1946 "Local / Custom" bucket).
172+
await ql.insert('sys_metadata', {
173+
type: 'object',
174+
name: ORPHAN,
175+
state: 'draft',
176+
package_id: null,
177+
metadata: JSON.stringify({ name: ORPHAN, label: 'Legacy', fields: { name: { type: 'text' } } }),
178+
});
179+
// Precondition: it is NOT owned by the base yet.
180+
expect(await ownedNames(ql, BASE)).not.toContain(ORPHAN);
181+
182+
// MIGRATE — adopt every loose item in the env into the base.
183+
const res = await protocol.reassignOrphanedMetadata({ targetPackageId: BASE });
184+
expect(res.success).toBe(true);
185+
expect(res.reassignedCount).toBeGreaterThan(0);
186+
expect(res.reassigned.map((r: any) => r.name)).toContain(ORPHAN);
187+
188+
// Postcondition: the row is now owned by the base — no longer an orphan.
189+
expect(await ownedNames(ql, BASE)).toContain(ORPHAN);
190+
const stillOrphan = ((await ql.find('sys_metadata', { where: {}, context: { isSystem: true } })) as any[]).filter(
191+
(r) => r.name === ORPHAN && (r.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata'),
192+
);
193+
expect(stillOrphan).toHaveLength(0);
194+
});
195+
});
196+
197+
describe('dogfood: duplicate a writable base (ADR-0070 D4)', () => {
198+
let stack: VerifyStack;
199+
let protocol: any;
200+
let ql: any;
201+
// namespace == base-id suffix, so duplicate's default re-namespacing applies.
202+
const SRC = 'app.dfdup';
203+
const DST = 'app.dfdup2';
204+
205+
beforeAll(async () => {
206+
stack = await bootStack(crmStack);
207+
protocol = await stack.kernel.getServiceAsync<any>('protocol');
208+
ql = await stack.kernel.getServiceAsync<any>('objectql');
209+
});
210+
afterAll(async () => {
211+
await stack?.stop?.();
212+
});
213+
214+
it('clones ACTIVE items into a new base, re-namespacing names AND rewriting references', async () => {
215+
// Two objects in the source base; the ticket carries a lookup to the customer
216+
// (an intra-package reference that must be rewritten to the clone's new name).
217+
// duplicate() only clones state:'active' rows, so both must be published.
218+
await protocol.saveMetaItem({
219+
type: 'object',
220+
name: 'dfdup_customer',
221+
item: { name: 'dfdup_customer', label: 'Customer', fields: { full_name: { type: 'text', label: 'Name' } } },
222+
packageId: SRC,
223+
mode: 'publish',
224+
});
225+
await protocol.saveMetaItem({
226+
type: 'object',
227+
name: 'dfdup_ticket',
228+
item: {
229+
name: 'dfdup_ticket',
230+
label: 'Ticket',
231+
fields: {
232+
title: { type: 'text', label: 'Title' },
233+
customer: { type: 'lookup', label: 'Customer', reference: 'dfdup_customer' },
234+
},
235+
},
236+
packageId: SRC,
237+
mode: 'publish',
238+
});
239+
240+
// DUPLICATE — clone the whole base into a new writable package + namespace.
241+
const res = await protocol.duplicatePackage({
242+
sourcePackageId: SRC,
243+
targetPackageId: DST,
244+
targetNamespace: 'dfdup2',
245+
});
246+
expect(res.success).toBe(true);
247+
expect(res.copiedCount).toBeGreaterThanOrEqual(2);
248+
expect(res.failedCount).toBe(0);
249+
250+
// Clones land in the target base, re-namespaced (never colliding with source).
251+
const dstNames = await ownedNames(ql, DST);
252+
expect(dstNames).toContain('dfdup2_customer');
253+
expect(dstNames).toContain('dfdup2_ticket');
254+
expect(dstNames.some((n) => /^dfdup_/.test(n))).toBe(false); // no un-renamed leftovers
255+
256+
// The lookup reference was rewritten to the cloned object's new name.
257+
const ticketRow = ((await ql.find('sys_metadata', { where: { package_id: DST }, context: { isSystem: true } })) as any[]).find(
258+
(r) => r.name === 'dfdup2_ticket',
259+
);
260+
const ticketMeta =
261+
typeof ticketRow?.metadata === 'string' ? JSON.parse(ticketRow.metadata) : ticketRow?.metadata;
262+
expect(ticketMeta?.fields?.customer?.reference).toBe('dfdup2_customer');
263+
});
264+
});

0 commit comments

Comments
 (0)