Skip to content

Commit fa00ebf

Browse files
fix(runtime): a self-hosted restart reads the kernel's own sys_metadata back into the registry (#20100)
Fixes #20071 Clause-②: no ## What was wrong `createStandaloneStack` (`packages/runtime/src/standalone-stack.ts`) is the composition behind `os dev` / `os serve` / `os start`. It stamps `environmentId: 'env_local'` (or whatever `OS_ENVIRONMENT_ID` names), and it built `new ObjectQLPlugin({ environmentId, runPlatformMigrations })` with no `hydrateMetadataFromDb`. `ObjectQLPlugin.start()` hydrates only `if (this.environmentId === undefined || this.hydrateMetadataFromDb)` (`packages/objectql/src/plugin.ts:767`). So every self-hosted boot: - skipped reading `sys_metadata`; - logged "Project kernel — skipping sys_metadata hydration (metadata sourced from artifact)". An object published at runtime then answered `404 OBJECT_NOT_FOUND` on the data API after a restart, while its row was still stored. This is the same deduction that the `[#9380]` note in the same file records for `runPlatformMigrations`. ## The fix: a declaration, not a deduction - `createStandaloneStack` constructs `new ObjectQLPlugin({ environmentId, runPlatformMigrations: cfg.runPlatformMigrations ?? true, hydrateMetadataFromDb: true })`. The `[#20071]` comment at that line carries the caution check below. - `StandaloneStackConfigSchema` and the published `StandaloneStackConfig` type are unchanged. - The only other edit in the file: #9380's docblock pointer "line ~567 below" now reads "`createStandaloneStack` below". - `packages/objectql/**` and `packages/spec/**` are untouched. Opt-out: none. No caller of `createStandaloneStack` can make either clause of the caution false; adding a key later is its own card, declared `Clause-②: yes (widening)`. ## The plugin's caution, clause by clause The caution is on `ObjectQLPluginOptions.hydrateMetadataFromDb` (`plugin.ts:174-177`): "Set this ONLY when the kernel's registry is per-instance isolated AND `sys_metadata` lives on the kernel's own local driver (no control-plane proxy)". Both clauses are properties of `createStandaloneStack` itself, not of its caller. 1. **Per-instance isolated registry: holds.** - The function constructs a fresh `ObjectQLPlugin` and passes no `ql`, so `init()` runs `this.ql = new ObjectQL(hostCtx)` (`plugin.ts:397`). - Each `ObjectQL` owns its registry: `private _registry: SchemaRegistry = new SchemaRegistry();` (`engine.ts:3061`, whose comment says "Each engine now owns its registry so kernels are fully isolated"). - `SchemaRegistry` (`registry.ts:1819`) has no static members. 2. **`sys_metadata` on the kernel's own local driver: holds.** - `SysMetadataObject` (`packages/metadata-core/src/objects/sys-metadata.object.ts:17`) declares no `datasource`, so it routes to the default driver. - The function composes exactly one datasource, `DefaultDatasourcePlugin` as `default` (ADR-0062 D1: "every unbound object routes to it"). - Every `databaseDriver` kind the function dispatches (`memory`, `sqlite`, `sqlite-wasm`, `postgres`, `mysql`, `mongodb`, `turso`) is a direct driver, never a control-plane proxy. - The hydration read is `this.engine.find('sys_metadata', { where: { state: 'active', organization_id: null } })` (`loadMetaFromDb` in `packages/metadata-protocol/src/protocol.ts`), through that same engine. ## Stop valve: every caller of `createStandaloneStack`, measured `git grep createStandaloneStack` at `a4ca69a9` finds three production callers, plus tests and fixtures: | caller | what it is | `sys_metadata` | hydrates after this PR | |:--|:--|:--|:--| | `packages/cli/src/commands/serve.ts:2740` | `os serve` / `os dev` / `os start` with a config | the stack's own `default` driver | yes (declared) | | `packages/runtime/src/default-host.ts:163` (`createDefaultHostConfig`) | artifact-only boot | same | yes (declared) | | `packages/cli/src/utils/schema-migrate.ts:303` (`bootSchemaStack`) | the one-shot funnel for 13 `os migrate *` / `os meta *` commands | same | yes (declared), see below | | runtime and cli tests and fixtures | memory or sqlite files | same | yes (declared) | No caller has a proxied or non-local `sys_metadata`, so the stop valve does not fire. Outside this repository: - An org-wide code search for `createStandaloneStack` returns only `objectstack-ai/duly`: tests and seed scripts of an ordinary standalone app on its own database. - `objectstack-ai/cloud` is **NOT MEASURED**. Control leg: the same search for `hydrateMetadataFromDb` returns 0 hits outside this repository, although the plugin's own docblock says the cloud single-env tenant runtime sets that option. So the search cannot see that repository. - Either way, both caution clauses hold inside the function, so an unmeasured embedder gets the same composition. ## Why `bootSchemaStack` hydrates too Ruling ① lets a read-only one-shot boot turn hydration off "if it genuinely does not need it". Measured, the one-shots need it, or are neutral: - **`os migrate plan`:** its unmanaged-table sweep (`packages/cli/src/utils/unmanaged-tables.ts` header) says the question "is only answerable when the composed object set actually MIRRORS what this deployment's `os serve` boot registers". After this PR, `os serve` registers runtime-authored objects. - **`os migrate files-to-references`:** it refuses an empty scan because "this command's verdict is what later authorises irreversible behaviour". Without hydration it would silently skip the file fields of runtime-authored objects. - **The read writes nothing:** `loadMetaFromDb` is `engine.find` plus registry registration plus log lines. A boot that defers DDL still defers the Phase-3 tables of what it hydrated. - **Measured at `c945f282`:** `duplicates.integration.test.ts` (boot included, database byte-identical after the run), `platform-migrations-arming.integration.test.ts` and the six `schema-migrate*` / `unmanaged-tables` integration suites all pass. The stack's construction is the same `true` at the current head. ## Ruling ④: the false log line "Project kernel — skipping sys_metadata hydration" is now unreachable on this stack. It sits only in the `else` of the gate above, and `hydrateMetadataFromDb` is a literal `true` that no caller can change. The new unit test pins the flag under every way an environment id is stamped. Observed directly: - on the unfixed source, the unit test's two boots each printed that line once; - on the fix, the second boot prints `Metadata restored from database to SchemaRegistry {"loaded":2,"errors":0,"invalid":0}` and no skip line. No edit to `packages/objectql` was needed. ## Reach beyond one object (mechanism assumption 6) `packages/runtime/src/standalone-stack-hydrate-metadata.test.ts` boots the real stack twice on one SQLite file: - **Boot 1** writes an env-wide `object` row **and** an env-wide `app` row through the protocol, and inserts one record. - **Boot 2** has both rows in the registry, and the record reads back through the engine. The `app` is not in the registry on the boot that wrote it. On this composition, the protocol's write-through for non-object types returns early on a kernel with an environment id (see Acceptance notes). So boot hydration is the only thing that puts it there, which makes it a sharp second leg. Org-scoped rows are deliberately not asserted. `loadMetaFromDb` reads `organization_id IS NULL` only (ADR-0005: per-org overlays are served on demand). Diagnostics hydration now surfaces at boot, reported and not suppressed. `loadMetaFromDb` and its `reportUnhydratableOrgScopedRows` (#6190) now run on every standalone boot: - a stored row that cannot register prints `[Protocol] [metadata_field_type_refused] …` at `error`, or `[Protocol] Failed to hydrate TYPE/NAME: …` / `[Protocol] [metadata_spec_invalid] …` at `warn`; - org-scoped rows of types that are not per-org overridable get one aggregated line. None of these fired in any suite run here. Against a real install's `sys_metadata` they are NOT MEASURED; the changeset names these lines for upgraders. ## The pin `packages/cli/test/package-restart-acceptance.integration.test.ts`: probe 2's `it.fails` is promoted to a plain `it`, and the file's header now describes the fixed state. A log-line assertion I added beside it was removed in this PR. The ablation leg showed it stays green on the unfixed build, because `os serve` does not print that INFO line at the pin's log level. ## Tests (head `46fd71b1`) - **The pin:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/package-restart-acceptance.integration.test.ts` gives `Tests 5 passed (5)`. `ablation-dist-preflight` shows the runtime `dist` carries the literal. - **Unit test:** `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/standalone-stack-hydrate-metadata.test.ts` gives `Tests 2 passed (2)`. - **Runtime suite:** `pnpm --filter @objectstack/runtime test` gives `Test Files 279 passed (279)`, `Tests 3906 passed | 1 skipped (3907)`. - **Typecheck:** `pnpm --filter @objectstack/runtime typecheck` exits 0, with `check:test-typecheck: OK … 27 file(s) / 191 error(s) / 69 pinned signature(s)` (ledger unchanged). - **Lint:** full `pnpm lint` (`eslint . --no-inline-config`) exits 0. - **Gates:** all 60 commands that `node scripts/pm/dispatch-gates.mjs --commands` derives at `46fd71b1` exit 0. The `--ran` reconciliation reads "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN". - **At `c945f282`**, before this revision: - the CLI unit project: `Test Files 224 passed (224)`, `Tests 3158 passed (3158)`; - `pnpm --filter @objectstack/cli typecheck`: exit 0; - CLI `--project integration` on 10 files (the pin, `duplicates`, `meta.stored-flow-resolution`, `platform-migrations-arming`, the five `schema-migrate*` files and `unmanaged-tables`): `Test Files 10 passed (10)`, `Tests 38 passed (38)`. This revision touches no CLI file, and the rest of the CLI integration project is declared to CI. **Ablation at `46fd71b1`, from committed state.** The mutation flipped the literal's `true` to `false` through `node scripts/ablation-replace.mjs`, anchored on the construction line. - **Mutation landed:** anchor x1 to x0, blob `b10937d6` to `dc0eab77`, and on-disk counts `true=0 false=1`. - **Reached `dist`:** after rebuilding `@objectstack/runtime`, `ablation-dist-preflight` reports the `true` spelling absent from all 6 dist files and the flipped spelling present in 2. - **Unit test:** 2 failed. The restart row fails at "hydr_widget is registered after the restart: expected undefined", the registry check just before its data read. - **Pin:** `1 failed | 4 passed`. Probe 2 fails with "GET /data/leave_request after a restart: {"error":"Object 'leave_request' is not registered","code":"OBJECT_NOT_FOUND"} … expected 404 to be 200". - **Restore:** - blob `b10937d6` == HEAD, and `git status --porcelain` is empty; - runtime rebuilt: the literal is present in 2 dist files and `false` absent from all 6; - the unit test gives 2 passed and the pin 5 passed. ## Acceptance notes (observations, not filed) - **Same deduction, non-object write-through:** `packages/metadata-protocol/src/protocol.ts` `applyRegistryWriteThrough` returns early for every non-object type when `this.environmentId !== undefined`. That is the same deduction class, one package over. On a standalone kernel, a runtime-saved `app` is absent from the registry on the boot that wrote it, until a listing or the next boot hydrates it; the new unit test's boot 1 measured this. No user-visible failure was measured, so it is recorded here, not filed. - **Comment drift:** `packages/objectql/src/plugin.ts` (the Phase-2 bridge comment) and `loadMetaFromDb`'s comment still call `SchemaRegistry` a process-wide singleton, while `engine.ts:3054-3061` says each engine now owns its own. Both files are read-only for this lane. ## Relations #17676 remains open: that card belongs to the engine seat, and this PR delivers the runtime half that its acceptance pin (PR #20069) waits on. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa04ea2 commit fa00ebf

4 files changed

Lines changed: 296 additions & 34 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a self-hosted restart reads the kernel's own `sys_metadata` back into the registry, so objects authored at runtime keep serving
6+
7+
An object created and published at runtime on a self-hosted install (Studio,
8+
`PUT /api/v1/meta/object/…`, `POST /api/v1/packages/<id>/publish-drafts`)
9+
answered `404 OBJECT_NOT_FOUND` on the data API after the next restart, while
10+
its `sys_metadata` row was still there and
11+
`GET /api/v1/meta/object/<name>/published` still served it. Every `os dev` / `os serve` / `os start` boot was affected.
12+
13+
**Cause.** `createStandaloneStack` stamps `environmentId: 'env_local'` on every
14+
boot (or whatever `OS_ENVIRONMENT_ID` names), and `ObjectQLPlugin.start()` read
15+
any environment id as "a per-project kernel whose metadata comes from an
16+
artifact or a control-plane proxy", so it skipped reading `sys_metadata` at
17+
boot. It logged `Project kernel — skipping sys_metadata hydration (metadata
18+
sourced from artifact)`, which was false on this composition. This is the same
19+
deduction that `runPlatformMigrations` was declared out of.
20+
21+
**Fix: a declaration, not a deduction.** `createStandaloneStack` now declares
22+
`hydrateMetadataFromDb: true` to `ObjectQLPlugin`, where it used to be deduced
23+
from the environment-id stamp. The plugin option's own caution holds for every
24+
boot this function builds, for two reasons:
25+
26+
- the registry is per-instance: the function constructs a fresh
27+
`ObjectQLPlugin`, which builds its own `ObjectQL` and `SchemaRegistry`;
28+
- `sys_metadata` is on the kernel's own driver: it declares no datasource, so it
29+
routes to the one `default` datasource the function composes, and every
30+
database driver kind the function dispatches is a direct driver, never a
31+
control-plane proxy.
32+
33+
What an upgraded install sees at boot:
34+
35+
- every env-wide `sys_metadata` row (`organization_id` NULL), any metadata type,
36+
is registered again. Org-scoped rows are still served on demand and are not
37+
read at boot (ADR-0005);
38+
- a stored row that cannot register now says so at boot, on lines that were
39+
never reached here before: `[Protocol] [metadata_field_type_refused] …` at
40+
`error`, `[Protocol] Failed to hydrate <type>/<name>: …` and
41+
`[Protocol] [metadata_spec_invalid] …` at `warn`. The same boot also reports
42+
org-scoped rows of types that are not per-org overridable, on one aggregated
43+
line. Each line names its remedy;
44+
- the one-shot `os migrate *` / `os meta *` commands hydrate too, so a plan or
45+
a scan covers runtime-authored objects the way the serving boot registers
46+
them. The read itself writes nothing, and a deferred-DDL boot
47+
(`os migrate plan`, `os migrate duplicates`) still defers the tables of what
48+
it read.

‎packages/cli/test/package-restart-acceptance.integration.test.ts‎

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,22 @@
2929
* 2. `GET /api/v1/data/<ns>_<obj>` — the object must be REGISTERED to serve.
3030
* 3. `GET /api/v1/meta/object/<ns>_<obj>/published` — the stored row.
3131
*
32-
* ## What this file finds at the head it landed on
32+
* ## What this file pins
3333
*
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.
34+
* All three probes cross the restart. Probe 1: the package comes back from
35+
* `sys_packages` (the `package-registry` mount). Probe 3: the published row is
36+
* still served. Probe 2: the data route serves the object again, because the
37+
* stock composition, `createStandaloneStack` (`packages/runtime`), DECLARES
38+
* `hydrateMetadataFromDb` on its `ObjectQLPlugin` (#20071), so
39+
* `ObjectQLPlugin.start()` reads the runtime-authored object back from
40+
* `sys_metadata`. Before that declaration the stack stamped `environmentId:
41+
* 'env_local'`, the plugin read the stamp as "a per-project kernel" and skipped
42+
* the read, and the data route answered `404 OBJECT_NOT_FOUND` after the
43+
* restart for an object that was published and serving before it. This file
44+
* landed with probe 2 as `it.fails` and went red on that fix, which promoted it
45+
* to a plain `it`. The other assertions below are harness health for probe 2:
46+
* the chain, the pre-restart agreement and the other two probes are asserted on
47+
* their own.
4848
*
4949
* Tier: the name carries no nightly tier (`scripts/nightly-tiers.mjs`), so it
5050
* runs in the per-PR and merge-queue Test Core run; it spawns the CLI, so
@@ -377,20 +377,13 @@ describe('#17676 ruling A\' item 5: the three probes across a restart of a stock
377377
expect((after?.published.body as { name?: unknown } | null)?.name).toBe(OBJECT);
378378
});
379379

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-
);
380+
// #17676's acceptance. Landed as `it.fails` while the stock standalone
381+
// composition skipped `sys_metadata` hydration; promoted on #20071, the change
382+
// that declares it (see the header).
383+
it('probe 2 after the restart — the data route serves the published object, so the three probes agree', () => {
384+
expect(
385+
after?.data.status,
386+
`GET /data/${OBJECT} after a restart: ${JSON.stringify(after?.data.body)} (code ${String(errorCode(after?.data.body))})`,
387+
).toBe(200);
388+
});
396389
});
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20071] A standalone boot reads its OWN `sys_metadata` back into the
4+
// registry, whatever environment id it stamps.
5+
//
6+
// `createStandaloneStack` stamps `environmentId: 'env_local'` (or whatever
7+
// `OS_ENVIRONMENT_ID` / `cfg.environmentId` names), and `ObjectQLPlugin.start()`
8+
// used to read ANY environment id as "a per-project kernel whose metadata comes
9+
// from an artifact or a control-plane proxy" and skip Phase-2 hydration. So an
10+
// object created and published at runtime kept its `sys_metadata` row across a
11+
// restart and was never registered again: the data API answered
12+
// `404 OBJECT_NOT_FOUND` for it (measured end to end by
13+
// `packages/cli/test/package-restart-acceptance.integration.test.ts`).
14+
//
15+
// Two halves, pinned where each is observable:
16+
//
17+
// 1. The DECLARATION, read off the plugin the stack hands the kernel — the
18+
// same seam `standalone-stack-default-environment-id.test.ts` reads the
19+
// stamped id from. It is asserted under all three ways an id gets stamped,
20+
// because the defect was precisely that the stamp decided hydration.
21+
// 2. The EFFECT, on a real kernel booted twice over one SQLite file: an
22+
// env-wide `object` row AND an env-wide `app` row written through the
23+
// protocol on boot 1 are in the second boot's registry, and a record
24+
// inserted on boot 1 is read back through the engine on boot 2. The `app`
25+
// row is there because the card measured one object only: hydration is
26+
// type-blind for env-wide rows (`loadMetaFromDb` registers objects through
27+
// `registerObject` and every other type through `registerItem`), and this
28+
// makes that reach a measurement instead of a reading of the code.
29+
//
30+
// ⛔ Org-scoped rows are NOT asserted here, deliberately: `loadMetaFromDb`
31+
// hydrates `organization_id IS NULL` rows only (ADR-0005 — a per-org overlay
32+
// is served on demand, never grafted into the registry every org shares), so an
33+
// org row absent after a restart is the design, not this defect.
34+
35+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
36+
import { mkdtempSync, rmSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { join } from 'node:path';
39+
import type { ObjectQL } from '@objectstack/objectql';
40+
import { Runtime } from './runtime.js';
41+
import { createStandaloneStack } from './standalone-stack.js';
42+
43+
// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE
44+
// LOAD. Each is reached below through a dynamic `import()` inside an `it()` body
45+
// (the factory and the kernel boot both import lazily) -- vitest clocks those,
46+
// while collection is clocked against nothing. See
47+
// `scripts/check-test-source-alias.mjs` (the clocked-window rule).
48+
import '@objectstack/metadata';
49+
import '@objectstack/objectql';
50+
import '@objectstack/service-datasource';
51+
52+
const OBJECTQL_PLUGIN = 'com.objectstack.engine.objectql';
53+
const BOOT_TIMEOUT = 120_000;
54+
55+
const OBJECT = 'hydr_widget';
56+
const APP = 'hydr_console';
57+
58+
/**
59+
* The hydration flag as `ObjectQLPlugin` actually holds it. TypeScript-private,
60+
* read on purpose: it is the one value `start()` gates Phase 2 on, so a pin that
61+
* re-declared the literal would stay green through a change that stopped
62+
* passing it.
63+
*/
64+
function hydrationFlag(plugins: readonly unknown[]): { environmentId: unknown; hydrateMetadataFromDb: unknown } {
65+
const objectql = plugins.find((p: any) => p?.name === OBJECTQL_PLUGIN) as any;
66+
expect(objectql, `stack must carry ${OBJECTQL_PLUGIN}`).toBeDefined();
67+
return { environmentId: objectql.environmentId, hydrateMetadataFromDb: objectql.hydrateMetadataFromDb };
68+
}
69+
70+
async function boot(plugins: readonly unknown[]) {
71+
const runtime = new Runtime({ cluster: false });
72+
const kernel = runtime.getKernel();
73+
for (const p of plugins) await kernel.use(p as any);
74+
await kernel.bootstrap();
75+
return kernel;
76+
}
77+
78+
describe('[#20071] createStandaloneStack declares sys_metadata hydration instead of deducing it from the environment id', () => {
79+
const dirs: string[] = [];
80+
const kernels: any[] = [];
81+
let savedEnvId: string | undefined;
82+
let savedHome: string | undefined;
83+
84+
function tempDir(tag: string): string {
85+
const dir = mkdtempSync(join(tmpdir(), `os-20071-${tag}-`));
86+
dirs.push(dir);
87+
return dir;
88+
}
89+
90+
beforeEach(() => {
91+
savedEnvId = process.env.OS_ENVIRONMENT_ID;
92+
savedHome = process.env.OS_HOME;
93+
delete process.env.OS_ENVIRONMENT_ID;
94+
process.env.OS_HOME = tempDir('home');
95+
});
96+
97+
afterEach(async () => {
98+
for (const k of kernels.splice(0)) {
99+
try { await k.shutdown(); } catch { /* noop */ }
100+
}
101+
if (savedEnvId === undefined) delete process.env.OS_ENVIRONMENT_ID;
102+
else process.env.OS_ENVIRONMENT_ID = savedEnvId;
103+
if (savedHome === undefined) delete process.env.OS_HOME;
104+
else process.env.OS_HOME = savedHome;
105+
for (const d of dirs.splice(0)) {
106+
try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ }
107+
}
108+
});
109+
110+
it('hands ObjectQLPlugin `hydrateMetadataFromDb: true` under every way an environment id is stamped', async () => {
111+
const byDefault = await createStandaloneStack({ databaseUrl: 'memory://issue-20071-default' });
112+
expect(hydrationFlag(byDefault.plugins)).toEqual({ environmentId: 'env_local', hydrateMetadataFromDb: true });
113+
114+
process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment';
115+
const byEnv = await createStandaloneStack({ databaseUrl: 'memory://issue-20071-env' });
116+
expect(hydrationFlag(byEnv.plugins)).toEqual({
117+
environmentId: 'env_from_the_environment',
118+
hydrateMetadataFromDb: true,
119+
});
120+
121+
const byConfig = await createStandaloneStack({
122+
environmentId: 'env_from_the_config',
123+
databaseUrl: 'memory://issue-20071-cfg',
124+
});
125+
expect(hydrationFlag(byConfig.plugins)).toEqual({
126+
environmentId: 'env_from_the_config',
127+
hydrateMetadataFromDb: true,
128+
});
129+
}, BOOT_TIMEOUT);
130+
131+
it('an env-wide object and an env-wide app written at runtime are registered again after a restart on the same database file', async () => {
132+
const dir = tempDir('restart');
133+
const databaseUrl = `file:${join(dir, 'hydrate.db')}`;
134+
const stackConfig = { projectRoot: dir, databaseUrl, skipSeedData: true, runPlatformMigrations: false } as const;
135+
136+
// ── boot 1: author both rows at runtime, and one record ────────────────
137+
const first = await boot((await createStandaloneStack(stackConfig)).plugins);
138+
kernels.push(first);
139+
const protocol: any = first.getService('protocol');
140+
await protocol.saveMetaItem({
141+
type: 'object',
142+
name: OBJECT,
143+
item: {
144+
name: OBJECT,
145+
label: 'Widget',
146+
sharingModel: 'private',
147+
fields: { title: { type: 'text', label: 'Title' } },
148+
},
149+
});
150+
await protocol.saveMetaItem({ type: 'app', name: APP, item: { name: APP, label: 'Hydration Console' } });
151+
const engine1 = first.getService<ObjectQL>('objectql');
152+
// Harness health: both rows are persisted env-wide and active — exactly
153+
// the population `loadMetaFromDb` selects — so their absence from the
154+
// next boot's registry can only be the boot's doing.
155+
for (const [type, name] of [['object', OBJECT], ['app', APP]] as const) {
156+
const stored: Array<{ state?: unknown; organization_id?: unknown }> =
157+
await engine1.find('sys_metadata', { where: { type, name } });
158+
expect(
159+
stored.map((r) => ({ state: r.state, organization_id: r.organization_id ?? null })),
160+
`boot 1 persists ${type}/${name} as one env-wide active row`,
161+
).toEqual([{ state: 'active', organization_id: null }]);
162+
}
163+
// The object is also serving on boot 1 (its write-through is not gated on
164+
// the environment id). The app is NOT in this boot's registry: the
165+
// protocol's write-through for every non-object type returns early on a
166+
// kernel with an environment id, so on this composition boot hydration
167+
// is the only path that puts it there — which is what makes it a sharp
168+
// second leg below.
169+
expect(engine1.registry.getObject(OBJECT)?.name, 'boot 1 registers the object it just saved').toBe(OBJECT);
170+
await engine1.insert(OBJECT, { title: 'survives the restart' });
171+
await first.shutdown();
172+
kernels.splice(kernels.indexOf(first), 1);
173+
174+
// ── boot 2: same file, nothing authored — only hydration can bring them back
175+
const second = await boot((await createStandaloneStack(stackConfig)).plugins);
176+
kernels.push(second);
177+
const engine2 = second.getService<ObjectQL>('objectql');
178+
expect(engine2.registry.getObject(OBJECT)?.name, `${OBJECT} is registered after the restart`).toBe(OBJECT);
179+
expect(
180+
engine2.registry.getItem<{ name?: string }>('app', APP)?.name,
181+
`app ${APP} is registered after the restart`,
182+
).toBe(APP);
183+
const rows: Array<{ title?: unknown }> = await engine2.find(OBJECT, {});
184+
expect(rows.map((r) => r.title)).toEqual(['survives the restart']);
185+
}, BOOT_TIMEOUT);
186+
});

‎packages/runtime/src/standalone-stack.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ export const StandaloneStackConfigSchema = z.object({
223223
* Defaults to `true`, and that default is the fix: a standalone kernel
224224
* OWNS its local platform tables, which is what the gate in
225225
* `assembleMetadataProtocol` always meant to say. It used to deduce that
226-
* from `environmentId === undefined`, and line ~567 below stamps
227-
* `'env_local'` on every boot — so the block never ran and #8686's
226+
* from `environmentId === undefined`, and `createStandaloneStack` below
227+
* stamps `'env_local'` on every boot — so the block never ran and #8686's
228228
* "covers every existing deployment" half covered no self-hosted install
229229
* at all.
230230
*
@@ -754,7 +754,42 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
754754
// every self-hosted install. A standalone kernel owns its local
755755
// platform tables — say so — and let a read-only one-shot boot turn
756756
// it off explicitly.
757-
new ObjectQLPlugin({ environmentId, runPlatformMigrations: cfg.runPlatformMigrations ?? true }),
757+
//
758+
// [#20071] `hydrateMetadataFromDb` is declared here for the same
759+
// reason. `ObjectQLPlugin.start()` reads `sys_metadata` back into the
760+
// registry only when `environmentId === undefined` or this option is
761+
// set, and read the `'env_local'` stamp above as "a per-project kernel
762+
// whose metadata comes from an artifact or a control-plane proxy". So
763+
// every self-hosted restart dropped the objects authored at runtime
764+
// (Studio, `PUT /api/v1/meta/*`, `publish-drafts`) from the registry
765+
// while their rows stayed in the database, and the data API answered
766+
// `404 OBJECT_NOT_FOUND` for them.
767+
//
768+
// The option's own caution — set it ONLY when the kernel's registry is
769+
// per-instance isolated AND `sys_metadata` lives on the kernel's own
770+
// local driver — holds here, clause by clause:
771+
//
772+
// - per-instance registry: this function constructs a fresh
773+
// `ObjectQLPlugin` with no shared `ql`, so its `init()` builds a new
774+
// `ObjectQL`, and each `ObjectQL` owns its `SchemaRegistry`;
775+
// - the kernel's own driver: `sys_metadata` declares no datasource,
776+
// so it routes to the one `default` datasource this function
777+
// composes (`defaultDatasourcePlugin` above), and every
778+
// `databaseDriver` kind dispatched above is a direct driver, never
779+
// a control-plane proxy.
780+
//
781+
// Both facts are properties of THIS function, not of its caller, so no
782+
// caller can make either clause false — which is why it is a literal
783+
// and not a config field. The one-shot `os migrate *` / `os meta *`
784+
// funnel (`bootSchemaStack`) wants it too: those commands diff and scan
785+
// the object set the serving boot registers, and the hydration read
786+
// writes nothing (a boot that defers DDL still defers the tables of
787+
// what it hydrated).
788+
new ObjectQLPlugin({
789+
environmentId,
790+
runPlatformMigrations: cfg.runPlatformMigrations ?? true,
791+
hydrateMetadataFromDb: true,
792+
}),
758793
];
759794
if (artifactBundle) {
760795
plugins.push(new AppPlugin(artifactBundle, undefined, {

0 commit comments

Comments
 (0)