Skip to content

Commit 7f1de2e

Browse files
test(cli): pin the three-probe restart acceptance for an API-created package (#20069)
Part of #17676 — ruling A′ item 5, the acceptance. #17676 remains open: at this head probe 2 disagrees after a restart, and its cause is in `packages/runtime` (the `domain:cli` lane), outside this card's file surface. Clause-②: no ## What this adds - **`packages/cli/test/package-restart-acceptance.integration.test.ts`**. This is a declared cross-lane, test-only edit: one new test file under `packages/cli`, and no source change in `packages/cli` or `packages/rest`. - It boots `os serve --dev` twice on ONE SQLite file. The host app declares neither `marketplace` nor `package-registry` (the `examples/app-crm` shape), so this is the stock composition. - On the first boot it runs the card's repro chain: `POST /api/v1/packages` with the body Studio's create dialog sends, then `PUT /api/v1/meta/object/leave_request?mode=draft&package=com.example.leave`, then `POST /api/v1/packages/com.example.leave/publish-drafts`. It reads the three probes, stops, boots again, and reads them again. - Harness: it reuses the shared spawn helpers in `test/helpers/serve-process.ts` (`CLI`, `TSX`, `childEnv`, `probeThroughChild`, the port errors). It also follows the long-lived-boot pattern of `serve-package-registry-always-on.e2e.test.ts`. That file's `bootServe` / `stop` are file-local, so they are repeated here rather than lifted: lifting them would edit a `domain:cli` file outside this card's surface. ## The three probes, measured | probe | route | before restart | after restart | |:--|:--|:--|:--| | 1 · Studio writable list | `GET /api/v1/packages`, rows whose `scope` is not `system`/`cloud` and whose `writable` is `true` | `com.example.leave` listed | listed | | 2 · data | `GET /api/v1/data/leave_request` | **200** | **404** `OBJECT_NOT_FOUND` — `Object 'leave_request' is not registered` | | 3 · published | `GET /api/v1/meta/object/leave_request/published` | 200 | 200 | **Where probe 1's route comes from.** objectui at the pinned `.objectui-sha` `62597c58`: - `packages/app-shell/src/views/studio-design/BuilderLanding.tsx` renders the heading `engine.studio.landing.mineHeading` ("My packages (writable)", upper-cased by CSS) and the empty state `engine.studio.landing.noneWritable` ("No writable packages yet — create one to start."). - It lists `fetchPackages()` filtered to `writable`. - `studio-design/packages-io.ts` `fetchPackages()` is ONE read, `fetch('/api/v1/packages')`, with no query parameters. `parsePackages` drops `scope: system | cloud` rows and reads the row's top-level `writable`. - On the server, `GET /packages` is `packages/runtime/src/domains/packages.ts`: `registry.getAllPackages()`, then `withWritableVerdict`. - The pin's `studioWritableIds` is that reader. It leaves out the client's fallback for servers that send no `writable`. ## Why probe 2 disagrees: root cause, measured 1. For a config app, the stock `serve` composition is `createStandaloneStack` (`packages/runtime/src/standalone-stack.ts`). It builds `new ObjectQLPlugin({ environmentId, runPlatformMigrations })` with `environmentId` defaulting to `'env_local'` and no `hydrateMetadataFromDb`. 2. `ObjectQLPlugin.start()` hydrates `sys_metadata` only when `environmentId === undefined || hydrateMetadataFromDb`. Otherwise it logs `Project kernel — skipping sys_metadata hydration (metadata sourced from artifact)`. A debug-level reboot on the same database file prints exactly that line. 3. The published row sits in `sys_metadata` with `state = active`, `organization_id` NULL and `package_id = com.example.leave`. That is exactly what `loadMetaFromDb` selects, but the row is never read. So the object is never re-registered and the data route answers 404, while the published read answers 200 straight off the stored row. Reading `GET /api/v1/meta/object/leave_request` and the object list on the live second boot does not change the 404. 4. **Diagnostic, never committed.** I added only `hydrateMetadataFromDb: true` to that constructor call and rebuilt `@objectstack/runtime`. On the same database file, probe 2 then answers 200 after the restart. Through this pin (leg B below), the other four assertions pass and the `it.fails` goes red. So all three probes agree. 5. **Not fixed here.** `packages/runtime` is the `domain:cli` lane, outside this card's file surface. The skip line's own words ("metadata sourced from artifact") are false on this composition, where runtime-authored metadata lives in `sys_metadata`. That is part of the same defect, not a second one. ## How the agreement is pinned - Probe 2 after the restart is an **`it.fails`**, following the precedent in `packages/cli/test/commands.test.ts`: known-broken, promote to a plain assertion once fixed. - `it.fails` is green on ANY failure, so four harness-health assertions stand beside it: the chain lands (201 / 200 / `outcome: 'published'`), the three probes agree before the restart, probe 1 holds after it, and probe 3 holds after it. - It goes red on the change that makes the composition re-register runtime-authored objects. That change promotes it to `it`, and it is the one that meets #17676's acceptance. ## Tier - The file name carries no nightly tier (`scripts/nightly-tiers.mjs`). It spawns the CLI, so `vitest-tiers.ts` places it in the `integration` project, and it runs in the per-PR and merge-queue Test Core. - Measured with `vitest list --filesOnly`: `--project integration` lists it 1 time and `--project unit` 0 times. Under `OS_TEST_TIERS=nightly` it is listed 0 times; the control, `serve-package-registry-always-on.e2e.test.ts`, is listed 1 time. - Cost: 22–23 s held under the verify lock per run (two boots). - Why not nightly: the change that fixes probe 2 lives in a dependency of this suite. Test Core therefore runs this file on that pull request, so the `it.fails` flips there rather than on a nightly after the change lands. ## Before / after (every run at `8af659d715`, every mutation through `scripts/ablation-replace.mjs`, each restore proven by blob equality with HEAD and an empty `git diff HEAD`) | run | mutation | result | |:--|:--|:--| | head | none | 4 passed, 1 expected fail | | reason | `it.fails(` → `it(` in this file | 1 failed: `GET /data/leave_request after a restart: {"error":"Object 'leave_request' is not registered","code":"OBJECT_NOT_FOUND","object":"leave_request"} (code OBJECT_NOT_FOUND): expected 404 to be 200`; 4 passed | | ablation A | the `'package-registry'` key of `Serve.CAPABILITY_PROVIDERS` renamed, so the key is absent. `bin/run-dev.js` runs `serve.ts` from `src` through tsx, so there is no dist hop | 1 failed (probe 1 after the restart), 3 passed, 1 expected fail. The failure transcript's plugin row carries 0 `PackageServicePlugin` | | leg B | `hydrateMetadataFromDb: true` in `createStandaloneStack`, through `dist`: `@objectstack/runtime` rebuilt, `ablation-dist-preflight` finds the marker in `index.js` and `index.cjs` | 4 passed; the `it.fails` is red with `Expect test to fail` | For the restore, `@objectstack/runtime` was rebuilt from pristine source and `ablation-dist-preflight --absent` exited 0 (the marker is absent from all 6 built files). It was checked again after the full package build below. ## Tests and gates (at `8af659d715`) - **The pin:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/package-restart-acceptance.integration.test.ts` gives 1 file: 4 passed, 1 expected fail. - **Unit project:** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gives 224 files and 3158 tests passed. That includes the tier partition pin `test/vitest-tiers-partition.test.ts`. - **Typecheck:** `pnpm --filter @objectstack/cli typecheck` exits 0. The `check:test-typecheck` ledger is unchanged (3 files / 28 errors / 6 signatures). The new file is in the `tsconfig.test.json` program (`--listFiles`: 1). - **Rest of the integration project:** declared to CI. A new standalone test file shares no state with the other 51 files. - **ESLint, narrowed:** `eslint --no-inline-config --format json` on the new file reports 1 file, 0 errors, 0 warnings. The population is eslint's own `--print-config` for that file. Invariance: the config enables no type-aware linting (`eslint.config.mjs` says so, and the resolved `parserOptions` carry no `project`), so adding one file cannot change any other file's verdict. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the real diff gives 48 commands, and all 48 finish at exit 0. Two were NOT MEASURED on the first pass: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET, 12 packages without `dist`) and `check:type-check-debt` (exit 124, my 280 s per-command timeout). Both exit 0 after a full package build (turbo, 72/72). `--ran` reports 48 derived, 48 run, 0 NOT-MEASURED, 0 UNRUN. - **Citations:** `node scripts/check-issue-citations.mjs --base 2274894` exits 0 but reads 0 files. `packages/**/*.test.ts` is a declared deferred surface of that gate, so its green says nothing about this diff. The only number this file cites is #17676. ## Changeset None; `skip-changeset` applies. `@objectstack/cli` ships `files: ["dist", "README.md", "CHANGELOG.md"]` and builds from `src`. After `pnpm --filter @objectstack/cli build`, a grep of those paths for `studioWritableIds` / `package-restart-acceptance` finds 0 files. The control, `PackageServicePlugin`, is found in 1 `dist` file. ## Acceptance notes - **H1 holds at this head.** The stock boot's banner lists `PackageServicePlugin` once, and the install logs no in-memory-only line. - **Protocol warning on every restart.** Each restart warns `package 'com.example.leave' declares no engines.protocol range; rehydrating without a compatibility check (ADR-0087)` for a package created with Studio's create-dialog body, which sends no `engines`. This is an observation and is not filed. - **Harness duplication.** `bootServe` / `stop` now exist in two `packages/cli/test` files (see above). Lifting them into `test/helpers/serve-process.ts` is the `domain:cli` seat's call. - **`origin/main` moved.** It moved 5 commits since this branch's base `2274894cc`. The only overlap with the packages this pin exercises is `packages/objectql/src/validation/rule-validator.ts` (validation-rule traversal), which is not the hydration seam. I did not merge `main` in; the merge queue rebuilds on the current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf37b99 commit 7f1de2e

1 file changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #17676 ruling A' item 5 — the card's acceptance, run as HTTP against the real
5+
* `os serve` composition across a restart on ONE database file:
6+
*
7+
* 「The three probes (Studio writable list, `GET /api/v1/data/<ns>_<obj>`,
8+
* `GET /api/v1/meta/object/…/published`) agree after a restart — that is
9+
* the acceptance.」
10+
*
11+
* The chain is the card's own repro, on a host app that declares neither
12+
* `marketplace` nor `package-registry` (the `examples/app-crm` shape):
13+
* `POST /api/v1/packages` (a writable base, the body Studio's create dialog
14+
* sends) → `PUT /api/v1/meta/object/<obj>?mode=draft&package=<id>` →
15+
* `POST /api/v1/packages/<id>/publish-drafts` → stop → boot again → probes.
16+
*
17+
* ## The three probes, and where each one's route comes from
18+
*
19+
* 1. Studio writable list — Studio's builder landing (objectui
20+
* `packages/app-shell/src/views/studio-design/BuilderLanding.tsx`, heading
21+
* `engine.studio.landing.mineHeading` = "My packages (writable)", empty
22+
* state "No writable packages yet") lists `fetchPackages()` filtered to
23+
* `writable`. `fetchPackages()` (`studio-design/packages-io.ts`) is ONE
24+
* read: `GET /api/v1/packages`, no query parameters; `parsePackages` drops
25+
* `scope: system | cloud` rows and takes the row's top-level `writable`
26+
* boolean — the server's verdict (`withWritableVerdict` in
27+
* `packages/runtime/src/domains/packages.ts`). {@link studioWritableIds}
28+
* is that reader, minus the fallback for servers that predate `writable`.
29+
* 2. `GET /api/v1/data/<ns>_<obj>` — the object must be REGISTERED to serve.
30+
* 3. `GET /api/v1/meta/object/<ns>_<obj>/published` — the stored row.
31+
*
32+
* ## What this file finds at the head it landed on
33+
*
34+
* Probes 1 and 3 cross the restart: the package comes back from `sys_packages`
35+
* (the `package-registry` mount) and the published row is still served. Probe
36+
* 2 does NOT: after the restart the data route answers `404 OBJECT_NOT_FOUND`
37+
* for an object that was published and serving before it. The stock
38+
* composition is `createStandaloneStack` (`packages/runtime`), which builds
39+
* `ObjectQLPlugin({ environmentId: 'env_local', … })` without
40+
* `hydrateMetadataFromDb`, so `ObjectQLPlugin.start()` logs `Project kernel —
41+
* skipping sys_metadata hydration` and never re-registers a runtime-authored
42+
* object. The agreement is therefore pinned as `it.fails` (the precedent is
43+
* `commands.test.ts`): it goes red on the change that makes the three probes
44+
* agree, and that change promotes it to a plain `it`. Every other assertion
45+
* below is harness health for it — `it.fails` is green on ANY failure, so the
46+
* chain, the pre-restart agreement and the two probes that do survive are
47+
* asserted on their own.
48+
*
49+
* Tier: the name carries no nightly tier (`scripts/nightly-tiers.mjs`), so it
50+
* runs in the per-PR and merge-queue Test Core run; it spawns the CLI, so
51+
* `vitest-tiers.ts` puts it in the `integration` project. A queue-tier file is
52+
* deliberate: the change that fixes probe 2 lives in a package this suite
53+
* depends on, so the `it.fails` flips on THAT pull request, not on a nightly
54+
* after it lands.
55+
*/
56+
57+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
58+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
59+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
60+
import { tmpdir } from 'node:os';
61+
import { join } from 'node:path';
62+
import {
63+
CLI,
64+
E2E_SECRET_KEY,
65+
TSX,
66+
childEnv,
67+
portContentionError,
68+
portDriftError,
69+
probeThroughChild,
70+
randomPort,
71+
} from './helpers/serve-process.js';
72+
73+
/** The banner's tail — every row above it has printed. */
74+
const READY = /Press Ctrl\+C to stop/;
75+
76+
/** The writable base the chain creates, and the object it publishes into it. */
77+
const PKG_ID = 'com.example.leave';
78+
const NAMESPACE = 'leave';
79+
const OBJECT = `${NAMESPACE}_request`;
80+
81+
/** A host app shaped like `examples/app-crm`: it declares neither capability token. */
82+
const HOST_CONFIG = `
83+
export default {
84+
manifest: {
85+
id: 'com.example.acceptancehost',
86+
namespace: 'acceptancehost',
87+
version: '1.0.0',
88+
type: 'app',
89+
name: 'acceptance host',
90+
},
91+
objects: [{
92+
name: 'acceptancehost_task',
93+
label: 'Task',
94+
sharingModel: 'private',
95+
fields: { title: { type: 'text', label: 'Title' } },
96+
}],
97+
};
98+
`;
99+
100+
interface LiveServe {
101+
child: ChildProcessWithoutNullStreams;
102+
/** Everything the child has printed so far, both streams. */
103+
output: () => string;
104+
}
105+
106+
const children: ChildProcessWithoutNullStreams[] = [];
107+
const dirs: string[] = [];
108+
109+
/** Boot `os serve --dev` on `db` and keep it running until {@link stop}. */
110+
function bootServe(dir: string, port: string, db: string): Promise<LiveServe> {
111+
return new Promise((resolveBoot, rejectBoot) => {
112+
const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port, '--dev'], {
113+
cwd: dir,
114+
stdio: ['pipe', 'pipe', 'pipe'],
115+
// `childEnv`, never a bare `...process.env` — see its header.
116+
env: childEnv({
117+
NO_COLOR: '1',
118+
OS_DATABASE_URL: db,
119+
OS_LOG_LEVEL: '',
120+
OS_DISABLE_CONSOLE: '1',
121+
OS_SECRET_KEY: E2E_SECRET_KEY,
122+
}),
123+
}) as ChildProcessWithoutNullStreams;
124+
children.push(child);
125+
126+
const what = `os serve --dev on ${db}`;
127+
let out = '';
128+
let settled = false;
129+
const settle = (err: Error | null) => {
130+
if (settled) return;
131+
settled = true;
132+
clearTimeout(timer);
133+
if (err) rejectBoot(err);
134+
else resolveBoot({ child, output: () => out });
135+
};
136+
const timer = setTimeout(
137+
() => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
138+
150_000,
139+
);
140+
const onData = (d: unknown) => {
141+
out += String(d);
142+
// The child is the authority on the port it bound.
143+
if (READY.test(out)) settle(portDriftError(out, what, port));
144+
};
145+
child.stdout.on('data', onData);
146+
child.stderr.on('data', onData);
147+
child.on('exit', (code) =>
148+
settle(
149+
portContentionError(out, what, port)
150+
?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`),
151+
),
152+
);
153+
});
154+
}
155+
156+
async function stop(child: ChildProcessWithoutNullStreams): Promise<void> {
157+
if (child.exitCode !== null || child.signalCode !== null) return;
158+
await new Promise<void>((done) => {
159+
const give = setTimeout(() => {
160+
try {
161+
child.kill('SIGKILL');
162+
} catch {
163+
/* already gone */
164+
}
165+
done();
166+
}, 10_000);
167+
child.once('exit', () => {
168+
clearTimeout(give);
169+
done();
170+
});
171+
try {
172+
child.kill('SIGTERM');
173+
} catch {
174+
clearTimeout(give);
175+
done();
176+
}
177+
});
178+
}
179+
180+
interface Answer {
181+
status: number;
182+
body: unknown;
183+
}
184+
185+
/**
186+
* One HTTP exchange against `serve`, attributed to the child if the transport
187+
* fails. ⛔ No assertion goes inside it: the answer is returned and read by an
188+
* `it` below.
189+
*/
190+
function request(
191+
serve: LiveServe,
192+
what: string,
193+
url: string,
194+
init: { method?: string; token?: string; body?: unknown } = {},
195+
): Promise<Answer> {
196+
return probeThroughChild(
197+
{
198+
child: serve.child,
199+
transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`,
200+
label: 'package-restart-acceptance',
201+
what,
202+
},
203+
async () => {
204+
const r = await fetch(url, {
205+
method: init.method ?? 'GET',
206+
headers: {
207+
...(init.body !== undefined ? { 'content-type': 'application/json' } : {}),
208+
...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
209+
},
210+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
211+
});
212+
const text = await r.text();
213+
let body: unknown = text;
214+
try {
215+
body = JSON.parse(text);
216+
} catch {
217+
/* not JSON — keep the text */
218+
}
219+
return { status: r.status, body };
220+
},
221+
);
222+
}
223+
224+
/** The `--dev` admin's bearer token; `''` when sign-in did not answer one. */
225+
async function signIn(serve: LiveServe, base: string): Promise<{ status: number; token: string }> {
226+
const res = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, {
227+
method: 'POST',
228+
body: { email: 'admin@objectos.ai', password: 'admin123' },
229+
});
230+
const token = (res.body as { token?: unknown } | null)?.token;
231+
return { status: res.status, token: typeof token === 'string' ? token : '' };
232+
}
233+
234+
/**
235+
* Probe 1's reader: the package ids Studio's builder landing lists under "My
236+
* packages (writable)". Mirrors objectui `parsePackages` — `data ?? payload`,
237+
* then the array or its `packages`; the id from `manifest.id` then `id`;
238+
* `scope: system | cloud` rows dropped — and keeps a row only when the server
239+
* sent `writable: true`. The client's fallback for a server that sends no
240+
* `writable` is deliberately NOT copied: this server sends it, and a row
241+
* without it is a finding, not a row to guess about.
242+
*/
243+
function studioWritableIds(payload: unknown): string[] {
244+
const root = (payload as { data?: unknown } | null)?.data ?? payload;
245+
const raw = Array.isArray(root) ? root : ((root as { packages?: unknown } | null)?.packages ?? []);
246+
if (!Array.isArray(raw)) return [];
247+
const ids: string[] = [];
248+
for (const row of raw as Array<Record<string, unknown> | null>) {
249+
if (!row || typeof row !== 'object') continue;
250+
const manifest = (row.manifest ?? {}) as Record<string, unknown>;
251+
const id = String(manifest.id ?? row.id ?? '');
252+
if (!id) continue;
253+
if (manifest.scope === 'system' || manifest.scope === 'cloud') continue;
254+
if (row.writable === true) ids.push(id);
255+
}
256+
return ids;
257+
}
258+
259+
/** The error code of an ADR-0112 envelope, flat or nested. */
260+
function errorCode(body: unknown): unknown {
261+
const b = body as { code?: unknown; error?: { code?: unknown } } | null;
262+
return b?.code ?? b?.error?.code;
263+
}
264+
265+
interface ThreeProbes {
266+
writableList: Answer;
267+
data: Answer;
268+
published: Answer;
269+
}
270+
271+
async function threeProbes(serve: LiveServe, base: string, token: string): Promise<ThreeProbes> {
272+
return {
273+
writableList: await request(serve, 'probe 1 — the Studio writable list', `${base}/packages`, { token }),
274+
data: await request(serve, 'probe 2 — the data route', `${base}/data/${OBJECT}`, { token }),
275+
published: await request(serve, 'probe 3 — the published object', `${base}/meta/object/${OBJECT}/published`, {
276+
token,
277+
}),
278+
};
279+
}
280+
281+
afterAll(async () => {
282+
for (const child of children) await stop(child);
283+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
284+
}, 60_000);
285+
286+
describe('#17676 ruling A\' item 5: the three probes across a restart of a stock `os serve` on one database file', () => {
287+
let chain: { signIn: number; install: Answer; draft: Answer; publish: Answer } | undefined;
288+
let before: ThreeProbes | undefined;
289+
let after: ThreeProbes | undefined;
290+
let afterSignIn = 0;
291+
let secondBoot = '';
292+
293+
beforeAll(async () => {
294+
const dir = mkdtempSync(join(tmpdir(), 'pkg-restart-acceptance-'));
295+
dirs.push(dir);
296+
writeFileSync(join(dir, 'objectstack.config.ts'), HOST_CONFIG, 'utf8');
297+
writeFileSync(
298+
join(dir, 'package.json'),
299+
JSON.stringify({ name: 'pkg-restart-acceptance-fixture', private: true, type: 'module' }, null, 2),
300+
'utf8',
301+
);
302+
const db = join(dir, 'probe.db');
303+
const port = randomPort();
304+
const base = `http://localhost:${port}/api/v1`;
305+
306+
// ── boot 1: the card's repro, then the three probes while it serves ──────
307+
const first = await bootServe(dir, port, db);
308+
const auth = await signIn(first, base);
309+
const install = await request(first, 'step 1 — POST /packages', `${base}/packages`, {
310+
method: 'POST',
311+
token: auth.token,
312+
// Studio's create dialog body: a writable base carries no `scope`.
313+
body: { manifest: { id: PKG_ID, name: 'Leave', version: '0.1.0', type: 'app', namespace: NAMESPACE } },
314+
});
315+
const draft = await request(
316+
first,
317+
'step 2 — PUT the object draft into the package',
318+
`${base}/meta/object/${OBJECT}?mode=draft&package=${encodeURIComponent(PKG_ID)}`,
319+
{
320+
method: 'PUT',
321+
token: auth.token,
322+
body: {
323+
name: OBJECT,
324+
label: 'Leave Request',
325+
sharingModel: 'private',
326+
fields: { title: { type: 'text', label: 'Title' } },
327+
},
328+
},
329+
);
330+
const publish = await request(
331+
first,
332+
'step 3 — publish the package drafts',
333+
`${base}/packages/${encodeURIComponent(PKG_ID)}/publish-drafts`,
334+
{ method: 'POST', token: auth.token, body: {} },
335+
);
336+
chain = { signIn: auth.status, install, draft, publish };
337+
before = await threeProbes(first, base, auth.token);
338+
await stop(first.child);
339+
340+
// ── boot 2: same database file, same port, then the same three probes ────
341+
const second = await bootServe(dir, port, db);
342+
const auth2 = await signIn(second, base);
343+
afterSignIn = auth2.status;
344+
after = await threeProbes(second, base, auth2.token);
345+
secondBoot = second.output();
346+
await stop(second.child);
347+
}, 420_000);
348+
349+
it('the repro chain lands: install 201, draft 200, publish-drafts publishes the object', () => {
350+
expect(chain?.signIn, 'the --dev admin must be able to sign in').toBe(200);
351+
expect(chain?.install.status, `POST /api/v1/packages: ${JSON.stringify(chain?.install.body)}`).toBe(201);
352+
expect(chain?.draft.status, `PUT ?mode=draft: ${JSON.stringify(chain?.draft.body)}`).toBe(200);
353+
expect(chain?.publish.status, 'POST /publish-drafts').toBe(200);
354+
const outcome = (chain?.publish.body as { data?: { outcome?: unknown; published?: unknown } } | null)?.data;
355+
expect(outcome?.outcome, `publish-drafts outcome: ${JSON.stringify(chain?.publish.body)}`).toBe('published');
356+
expect(outcome?.published).toEqual([expect.objectContaining({ type: 'object', name: OBJECT })]);
357+
});
358+
359+
it('before the restart the three probes agree', () => {
360+
expect(studioWritableIds(before?.writableList.body), 'probe 1 — Studio writable list').toContain(PKG_ID);
361+
expect(before?.data.status, `probe 2 — GET /data/${OBJECT}: ${JSON.stringify(before?.data.body)}`).toBe(200);
362+
expect(before?.published.status, 'probe 3 — GET /meta/object/…/published').toBe(200);
363+
expect((before?.published.body as { name?: unknown } | null)?.name).toBe(OBJECT);
364+
});
365+
366+
it('probe 1 after the restart — Studio still lists the package as writable (it came back from sys_packages)', () => {
367+
expect(afterSignIn, 'the --dev admin must be able to sign in on the second boot').toBe(200);
368+
expect(after?.writableList.status, 'GET /api/v1/packages').toBe(200);
369+
expect(
370+
studioWritableIds(after?.writableList.body),
371+
`Studio's writable list after a restart — absent means the package lived in memory only:\n${secondBoot.slice(-3000)}`,
372+
).toContain(PKG_ID);
373+
});
374+
375+
it('probe 3 after the restart — the published object metadata is still served', () => {
376+
expect(after?.published.status, 'GET /meta/object/…/published').toBe(200);
377+
expect((after?.published.body as { name?: unknown } | null)?.name).toBe(OBJECT);
378+
});
379+
380+
// Known-broken at the head this landed on: the data route answers
381+
// `404 OBJECT_NOT_FOUND` after the restart, because the stock standalone
382+
// composition skips `sys_metadata` hydration (see the header). Promote to a
383+
// plain `it` in the change that makes the composition re-register
384+
// runtime-authored objects — that is the change that meets #17676's
385+
// acceptance, and it is the one that turns this red.
386+
it.fails(
387+
'probe 2 after the restart — the data route serves the published object, so the three probes agree '
388+
+ '(known-broken: the stock composition skips sys_metadata hydration; promote to a plain assertion once fixed)',
389+
() => {
390+
expect(
391+
after?.data.status,
392+
`GET /data/${OBJECT} after a restart: ${JSON.stringify(after?.data.body)} (code ${String(errorCode(after?.data.body))})`,
393+
).toBe(200);
394+
},
395+
);
396+
});

0 commit comments

Comments
 (0)