Skip to content

Commit 84c4fff

Browse files
fix(service-package, cli): os migrate plan / apply cover sys_packages, and the boot honours OS_SKIP_SCHEMA_SYNC for it (#23060)
Fixes #23022 Clause-②: no (the seat cannot read it without a build: a newly exported object definition or name flips it; the dev measures on the built declaration closure and stops on a flip) ## What this does, in the card's order 1. **The documented migration path covers `sys_packages`.** `PackageServicePlugin.init()` now declares the table as a kernel service, `package.storage`: a read-only catalog probe (SQLite `sqlite_master`, PostgreSQL `information_schema.tables` at `current_schema()`, MySQL `information_schema.tables` at `DATABASE()`) and the boot's own `ensurePackageTable`. `bootSchemaStack` (the funnel `os migrate plan` and `os migrate apply` share) lists the table as `create_table` when it is absent, and `flushSchemaDdl` creates it with those same statements. A migrate boot runs a provider's `init()` and never its `start()`, which is why the declaration lives in `init()`. 2. **Then the boot honours the flag.** `start()` reads the value `ObjectQLPlugin` resolved and published on the engine (`skipSchemaSync`, the seam #22984 added for plugin-audit and service-messaging; no second environment read). When it is set, `start()` sends no DDL and no probe, logs one `info` line naming `sys_packages`, the flag and `os migrate apply`, and still registers the `package` service. Unset, the boot is unchanged. Where the table's presence cannot be read (a default driver that names no SQL dialect, or a seam that returns no result set), the run says so in its composition notes and neither lists nor creates the table: "could not tell" is never read as "absent". ## Why not a registered object (the triage's preferred shape): measured, A3 holds A declared `sys_packages`, through `SqlDriver.initObjects` on SQLite (better-sqlite3) at this branch's base: - fresh database: `` `id` varchar(255) ``, `created_at` / `updated_at` typed `datetime` with a `strftime` default, every other column nullable, and `primary key (id)` alone. A second version of one package id is refused: `SQLITE_CONSTRAINT_PRIMARYKEY ... UNIQUE constraint failed: sys_packages`. - the same object adopted over a raw-DDL table: the table is untouched and `detectManagedDrift()` reports 0 entries. So fresh and existing deployments would get different tables, and the fresh one breaks multi-version publish. That is the A3 stop shape, so the managed-path entry keeps the raw DDL instead. `package-table.ts` already recorded the same result on PostgreSQL 16.14 and MySQL 8.0.46 (from the #21243 work). The create statements are unchanged, so `apply` creates exactly what the boot creates. On SQLite, `sqlite_master` holds byte-identical `CREATE TABLE` and `CREATE INDEX` text for both (integration reading below). PostgreSQL and MySQL were not reachable from this container: the new presence probes on those dialects are pinned by statement, not run live. ## Measured through the public doors (`os serve` with `OS_MIGRATE_AND_EXIT=1`, `os migrate plan --json`, `os migrate apply --yes --json`, SQLite) | reading | base `9f4b652e3` | this branch `6f0d23fde` | |---|---|---| | boot, flag unset, fresh | `sys_packages=true` | `sys_packages=true` | | boot, flag set, table dropped | `sys_packages=true` (DDL behind the flag) | `sys_packages=false`, one `NOT provisioned at boot` info line | | `os migrate plan --json`, table absent | `pending: []` | `pending: ["create_table:sys_packages"]` | | `os migrate apply --yes --json`, flag set | `message=in_sync`, still absent | `created: ["create_table:sys_packages"]`, present | | apply-created table equals boot-created table | n/a | `VERDICT shape apply == boot: true` | | boot, flag unset, table dropped (CONTROL) | re-created | re-created | ## A4: `Clause-②: no` holds, measured on the built declarations BASE sources of the three changed source files were built in this worktree (`service-package` tsup, `cli` tsc), then HEAD's were. The comparison covers 40 declaration files: service-package `dist/index.d.ts` and `index.d.cts`, the CLI's entry closure (`index` / `console` / `hook-body`, 37 files) and `dist/utils/schema-migrate.d.ts`. They were compared after the TypeScript printer stripped comments: `VERDICT IDENTICAL: 40 identical, 0 differ`. Positive control: injecting one member into `declare class PackageServicePlugin` and one export into `schema-migrate.d.ts` reads `DIFFER` for both. The restore was proven by blob hashes and an empty `git status --porcelain`, and two HEAD builds read identical. New names are module-private, and `package.storage` is a service name, not a declared one. ## Tests - `@objectstack/service-package`: new `managed-path.test.ts` (18 cases). On a real `node:sqlite` database, the declaration creates the `(id, version)` table and its index, and the probe is a read. Each dialect is asked its own catalog. "Could not tell" is `undefined`, and no SQL is sent. With the flag set, `start()` sends no DDL, the table stays absent, there is one info line, and the service is registered. The CONTROL rows are unset, `false` and the string `'true'`. `package-table.test.ts`'s context now captures only the `package` service, because `init()` registers one more. Suite: 7 files / 113 passed; `typecheck` exit 0 (its `tsc --listFiles` includes the new test). - `@objectstack/cli`: new `src/utils/schema-migrate.package-table.integration.test.ts`, through the real funnel with `OS_SKIP_SCHEMA_SYNC=1` set. It covers three steps. First, a plan on a never-started project lists `create_table sys_packages` with its seven columns and creates no file. Second, apply has nothing for the table before the flush; the flush creates it as `PRIMARY KEY (id, version)` with `TEXT` columns and `idx_packages_latest (id, created_at)`. Third, a re-plan lists nothing for it. - At HEAD `9c4be9e42` (after merging `main`): - CLI `--project unit`: 283 files / 4270 passed. - CLI `--project integration`, the 10 files on this path: the new one, `unmanaged-tables`, `schema-migrate.host-composition` / `readonly-probe` / `deferred-ddl` / `one-shot-family` / plain, `plan.boot-parity`, `plan.telemetry-sibling` and `apply.account-issuer-retirement`. 10 files / 119 passed. - CLI `typecheck`, including `check:test-typecheck`: exit 0. - The rest of the integration project is declared to CI. ### Ablations: each negative pin was put back and watched go red, then restored Mutations went through `scripts/ablation-replace.mjs`, with an EXIT/INT/TERM trap that restores from `HEAD`. Each restore was proven by blob hash and an empty `git diff HEAD`. - **The boot creates nothing under the flag.** Mutation: `if (false && bootSchemaSyncSkipped(objectql))`. Result: `managed-path.test.ts` 2 failed | 16 passed, `expected [ …(2) ] to deeply equal []` (the two DDL statements were sent) and `expected true to be false` (the table exists). - **The migrate boot does not create the table before the flush.** Mutation: the CLI calls `ensure()` while planning. Result: 1 failed, `expected [] to deeply equal [ { table: 'sys_packages', …(2) } ]`. - **The producer, across the package boundary.** Mutation: `PACKAGE_STORAGE_SERVICE` renamed to `'package.storage.ablated'` in service-package, which was then rebuilt. `ablation-dist-preflight` reported "marker present in 2 built files". Result: the CLI test failed 1, `expected [] to deeply equal [ { table: 'sys_packages', …(2) } ]`. Restore leg: rebuilt, preflight `--absent` reported "marker absent from all 6 built files", and the test passed 1. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `9c4be9e42` derived 68 families over 7 changed paths, with merge base `df7a13792`. All 68 ran, and all exited 0. `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered `PREREQUISITE NOT MET` (exit 3: packages outside the CLI closure were not built). They were re-run green on the same HEAD after a full turbo build. `--ran` reports: `68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED`. Lint was narrowed and the narrowing proven. `eslint --no-inline-config --format json` over the 6 changed `.ts` files reports 6 files, 0 errors, 0 warnings, ESLint exit 0. The config lints all 6 files (no ignore warning) and enables no type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. The changeset `.md` is outside eslint's population. ## Acceptance notes (not filed) - The seam is a string-named kernel service, `package.storage`. Like `skipSchemaSync`, it is an undeclared surface, chosen so that no name is added to either package's declarations. It is held together by the CLI integration test booting the real plugin, and the producer ablation shows that test failing when the name moves. A declared accessor would be the contract-first form and widen the published surface. Carrier: none. - The flag-half pins in service-package use an engine double that carries `skipSchemaSync: true`. service-package does not depend on `@objectstack/objectql`, and the unaliased-test-import ledger is shrink-only. The real producer was exercised once, through `os serve` (table above). Its publication is pinned by plugin-audit's and service-messaging's real-producer seam tests. Carrier: none. - On a flag deployment that boots before running `os migrate apply`, hydration's `list()` meets the missing table. The SQL driver logs its own `DATABASE_ERROR — no such table: sys_packages` line, and hydration logs at `debug`. That is loud enough to find, and the changeset says to run `os migrate apply` first. Carrier: none. - The presence probe asks for the table, not for `idx_packages_latest`. A table that exists without its index (reachable only by hand) is not re-planned. Carrier: none. - `os migrate plan`'s unmanaged-table sweep still lists `sys_packages` when the table exists. Its wording, "declared by no object in this plan", stays literally true: the table is declared by a service, not by an object. The boot-parity pins still expect it. Carrier: none. - `os migrate plan` on a fresh project now lists one more `create_table`. That is intended: it is what `apply` will create. - Merged `main` at `9c4be9e42` before opening. The incoming commits touch `packages/spec`, the security package-verb walls and CI, and none of this diff. #22985's region of `schema-migration-plugins.ts` is untouched: this change lives in `schema-migrate.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec7f7f8 commit 84c4fff

7 files changed

Lines changed: 702 additions & 19 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-package': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
`os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it
7+
8+
Clause-②: no
9+
10+
`sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise.
11+
12+
- **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it.
13+
- **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table.
14+
- **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23022 — `os migrate plan` lists `sys_packages` and `os migrate apply`
5+
* creates it, with `OS_SKIP_SCHEMA_SYNC=1` in the environment.
6+
*
7+
* ## What was broken
8+
*
9+
* `sys_packages` is the one table outside the object set:
10+
* `PackageServicePlugin.start()` creates it with raw DDL (it keys on
11+
* `(id, version)`, which a declared object cannot carry). A migrate boot runs a
12+
* provider's `init()` and never its `start()`, and no driver deferral sees a
13+
* table no object declares, so the plan never listed it and `apply` never
14+
* created it. A deployment that sets the flag — "skip the implicit `db:sync` on
15+
* boot. Use after running migrations manually." — therefore had no documented
16+
* way to get it, and the boot could not honour the flag without stranding the
17+
* deployment. Measured on `9f4b652e3` through `os migrate plan --json` and
18+
* `os migrate apply --yes --json`: no `sys_packages` in `pending`, and absent
19+
* after the apply.
20+
*
21+
* ## What this holds, through the REAL funnel
22+
*
23+
* The boot is `bootSchemaStack` exactly as the two commands call it, composing
24+
* the real `PackageServicePlugin` the way `os serve` mounts it (the always-on
25+
* `package-registry` provider), so the two ends of the `package.storage`
26+
* declaration — the plugin's `init()` and this CLI's reader — meet here:
27+
*
28+
* 1. plan (a never-started project): `create_table sys_packages` is pending,
29+
* with the table's seven columns, and no database file is brought into
30+
* existence;
31+
* 2. apply: the boot creates no `sys_packages` before the flush (the table is
32+
* still absent while the plan is on screen), the flush creates it and
33+
* reports it, and the table is the one the boot's own DDL creates —
34+
* `PRIMARY KEY (id, version)`, `TEXT` timestamps, `idx_packages_latest`;
35+
* 3. a plan over the migrated database lists nothing for it.
36+
*
37+
* The flag is SET for every boot here: the documented path must not depend on
38+
* it. That the boot itself creates nothing under the flag is
39+
* `@objectstack/service-package`'s `managed-path.test.ts`.
40+
*/
41+
42+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
43+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
44+
import { tmpdir } from 'node:os';
45+
import { join } from 'node:path';
46+
import Database from 'better-sqlite3';
47+
// Module-top side-effect loads, paid during COLLECTION rather than inside a
48+
// clocked test body: `bootSchemaStack` reaches these through dynamic
49+
// `import()`s that resolve through `dist/` (`check:test-source-alias`).
50+
import '@objectstack/runtime';
51+
import '@objectstack/objectql';
52+
import { bootSchemaStack, type SchemaStack } from './schema-migrate.js';
53+
54+
const COLUMNS = ['id', 'version', 'manifest', 'metadata', 'hash', 'created_at', 'updated_at'];
55+
56+
/** The env vars that outrank the unified project default (#6469). */
57+
const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'];
58+
const SET_ENV: Record<string, string> = {
59+
OS_SKIP_SCHEMA_SYNC: '1',
60+
OS_TELEMETRY_DB: '0',
61+
NODE_ENV: 'production',
62+
};
63+
64+
describe('os migrate plan / apply cover sys_packages, flag set (#23022)', () => {
65+
let dir: string;
66+
let dbFile: string;
67+
let stack: SchemaStack | null = null;
68+
const savedEnv: Record<string, string | undefined> = {};
69+
70+
const boot = (mode: 'plan' | 'apply'): Promise<SchemaStack> => bootSchemaStack({
71+
jsonOutput: false,
72+
projectRoot: dir,
73+
databaseUrl: `file:${dbFile}`,
74+
deferSchemaDdl: true,
75+
...(mode === 'plan' ? { readOnlyProbe: true } : {}),
76+
composeHostStack: true,
77+
composeServedPlatform: true,
78+
});
79+
80+
const packageWork = (s: SchemaStack) => s.pendingSchemaWork.filter((w) => w.table === 'sys_packages');
81+
82+
/** Read the file with a connection of OUR OWN — never the booted stack's. */
83+
function readTable() {
84+
const db = new Database(dbFile, { readonly: true });
85+
try {
86+
return {
87+
present: db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sys_packages'").all().length === 1,
88+
columns: db.prepare("SELECT name, type, \"notnull\" AS not_null FROM pragma_table_info('sys_packages') ORDER BY cid").all(),
89+
pk: db.prepare("SELECT name FROM pragma_table_info('sys_packages') WHERE pk > 0 ORDER BY pk").all(),
90+
index: db.prepare("SELECT name FROM pragma_index_info('idx_packages_latest') ORDER BY seqno").all(),
91+
};
92+
} finally {
93+
db.close();
94+
}
95+
}
96+
97+
beforeEach(() => {
98+
dir = mkdtempSync(join(tmpdir(), 'os-23022-'));
99+
dbFile = join(dir, 'app.db');
100+
writeFileSync(join(dir, 'objectstack.config.ts'), [
101+
'export default {',
102+
" manifest: { id: 'com.example.os23022', name: 'os23022', version: '0.0.0', type: 'app' },",
103+
" objects: [{ name: 'os23022_note', fields: { title: { type: 'text' } } }],",
104+
'};',
105+
'',
106+
].join('\n'));
107+
for (const key of [...OVERRIDING_ENV, ...Object.keys(SET_ENV), 'OS_ARTIFACT_PATH']) savedEnv[key] = process.env[key];
108+
for (const key of OVERRIDING_ENV) delete process.env[key];
109+
Object.assign(process.env, SET_ENV);
110+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); // deliberately absent
111+
});
112+
113+
afterEach(async () => {
114+
try { await stack?.shutdown(); } catch { /* torn down either way */ }
115+
stack = null;
116+
for (const [key, value] of Object.entries(savedEnv)) {
117+
if (value === undefined) delete process.env[key];
118+
else process.env[key] = value;
119+
}
120+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
121+
});
122+
123+
it('plan lists it, apply creates the boot\'s own table, and a plan after that lists nothing for it', async () => {
124+
// 1. plan — a never-started project.
125+
stack = await boot('plan');
126+
expect(stack.composition.servedBoot).toEqual({ mirrored: true });
127+
expect(packageWork(stack)).toEqual([{ table: 'sys_packages', kind: 'create_table', columns: COLUMNS }]);
128+
await stack.shutdown();
129+
stack = null;
130+
expect(existsSync(dbFile), 'a dry run brought the database into existence').toBe(false);
131+
132+
// 2. apply — nothing for it before the flush, the boot's table after it.
133+
stack = await boot('apply');
134+
expect(packageWork(stack)).toEqual([{ table: 'sys_packages', kind: 'create_table', columns: COLUMNS }]);
135+
expect(readTable().present, 'the migrate boot created sys_packages before the operator confirmed').toBe(false);
136+
const created = await stack.flushSchemaDdl();
137+
expect(created.filter((w) => w.table === 'sys_packages')).toEqual([
138+
{ table: 'sys_packages', kind: 'create_table', columns: COLUMNS },
139+
]);
140+
await stack.shutdown();
141+
stack = null;
142+
expect(readTable()).toEqual({
143+
present: true,
144+
columns: COLUMNS.map((name) => ({
145+
name,
146+
type: 'TEXT',
147+
not_null: ['created_at', 'updated_at'].includes(name) ? 0 : 1,
148+
})),
149+
pk: [{ name: 'id' }, { name: 'version' }],
150+
index: [{ name: 'id' }, { name: 'created_at' }],
151+
});
152+
153+
// 3. plan again — the table is there, so nothing is pending for it.
154+
stack = await boot('plan');
155+
expect(packageWork(stack)).toEqual([]);
156+
}, 300_000);
157+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,82 @@ function sortPendingSchemaWork(work: PendingSchemaWork[]): PendingSchemaWork[] {
244244
return work.sort((a, b) => a.table.localeCompare(b.table) || a.kind.localeCompare(b.kind));
245245
}
246246

247+
/**
248+
* [#23022] The kernel service under which `PackageServicePlugin.init()`
249+
* declares `sys_packages` — `@objectstack/service-package`'s
250+
* `PACKAGE_STORAGE_SERVICE`, read by name because the package exports no
251+
* constant for it.
252+
*/
253+
const PACKAGE_STORAGE_SERVICE = 'package.storage';
254+
255+
/** What {@link PACKAGE_STORAGE_SERVICE} answers with (`service-package`'s `PackageStorageDeclaration`). */
256+
interface PackageStorageDeclaration {
257+
readonly table: string;
258+
readonly columns: readonly string[];
259+
/** Read-only: `true` present, `false` absent, `undefined` could not tell. */
260+
present(): Promise<boolean | undefined>;
261+
/** The package registry's own `CREATE TABLE IF NOT EXISTS` and index. */
262+
ensure(): Promise<void>;
263+
}
264+
265+
/**
266+
* [#23022] `sys_packages` as pending schema work, when it is absent.
267+
*
268+
* ## Why the plan needs this
269+
*
270+
* `PackageServicePlugin.start()` creates `sys_packages` with raw DDL, because
271+
* the table cannot be a declared object (`service-package`'s
272+
* `package-table.ts` records the measurement: the driver's managed tables key
273+
* on `id` alone, and this one keys on `(id, version)`). So no driver deferral
274+
* ever listed it, and a deployment that sets `OS_SKIP_SCHEMA_SYNC=1` — whose
275+
* boot now creates nothing — was left with no way to get it from the documented
276+
* path. The plugin declares the table in `init()`, which a migrate boot runs,
277+
* and this lists it as `create_table` beside the objects' tables; `apply`'s
278+
* flush then runs the plugin's own statements, so `apply` creates exactly what
279+
* the boot creates.
280+
*
281+
* `null` when there is nothing to plan: the plugin is not composed, or the
282+
* table exists. When its presence could not be READ, that is not absence: a
283+
* note says the table was not examined, and nothing is planned.
284+
*/
285+
async function planPackageTable(
286+
kernel: unknown,
287+
notes: string[],
288+
): Promise<{ storage: PackageStorageDeclaration; work: PendingSchemaWork } | null> {
289+
let service: unknown;
290+
try {
291+
service = (kernel as { getService?: (name: string) => unknown }).getService?.(PACKAGE_STORAGE_SERVICE);
292+
} catch {
293+
return null; // not composed: this boot's deployment serves no package registry
294+
}
295+
if (service === undefined || service === null) return null;
296+
const storage = service as Partial<PackageStorageDeclaration>;
297+
if (
298+
typeof storage.table !== 'string' || !Array.isArray(storage.columns)
299+
|| typeof storage.present !== 'function' || typeof storage.ensure !== 'function'
300+
) {
301+
throw new Error(
302+
`The "${PACKAGE_STORAGE_SERVICE}" service does not have the shape this CLI reads (table, columns, present, `
303+
+ 'ensure), so sys_packages cannot be planned. @objectstack/cli and @objectstack/service-package must come '
304+
+ 'from the same release.',
305+
);
306+
}
307+
const declared = storage as PackageStorageDeclaration;
308+
const present = await declared.present();
309+
if (present === true) return null;
310+
if (present === undefined) {
311+
notes.push(
312+
`${declared.table} (the package registry's table) was NOT examined: its presence could not be read on this `
313+
+ 'database, so this run does not list it and does not create it.',
314+
);
315+
return null;
316+
}
317+
return {
318+
storage: declared,
319+
work: { table: declared.table, kind: 'create_table', columns: [...declared.columns] },
320+
};
321+
}
322+
247323
/**
248324
* Arms deferred-DDL mode on EVERY SQL driver the boot connects, before any of
249325
* them can schema-sync (#3917, #21391).
@@ -925,6 +1001,10 @@ export async function bootSchemaStack(
9251001
for (const d of deferral?.drivers ?? []) {
9261002
if (d.previewDeferredSchemaWork) pendingSchemaWork.push(...(await d.previewDeferredSchemaWork()));
9271003
}
1004+
// [#23022] …and the one table outside the object set, `sys_packages`, which
1005+
// no driver deferral can see. Read-only here; `flushSchemaDdl` creates it.
1006+
let packageTable = deferral ? await planPackageTable(kernel, composition.notes) : null;
1007+
if (packageTable) pendingSchemaWork.push(packageTable.work);
9281008
sortPendingSchemaWork(pendingSchemaWork);
9291009
// [#21529] The tables the held-back sync would create: measured absent.
9301010
const absentTables = new Set(
@@ -1008,6 +1088,12 @@ export async function bootSchemaStack(
10081088
for (const d of deferral?.drivers ?? []) {
10091089
if (d.flushDeferredSchemaDdl) performed.push(...(await d.flushDeferredSchemaDdl()));
10101090
}
1091+
// [#23022] The package registry's own DDL — the statements its boot runs.
1092+
if (packageTable) {
1093+
await packageTable.storage.ensure();
1094+
performed.push(packageTable.work);
1095+
packageTable = null;
1096+
}
10111097
// [#21529] The tables exist now; nothing is measured absent any more.
10121098
absentTables.clear();
10131099
deferral?.release();

0 commit comments

Comments
 (0)