Skip to content

Commit 63acf35

Browse files
feat(cli,metadata-protocol): os migrate packageless-overlays moves package-less overlay rows into .custom as declared overrides (#28689)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X4SFjuGUkFbJFHhsi2qJCMo)_ Fixes #28413 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — the one taught line this touches, `skills/objectstack-platform/SKILL.md`'s "a package-unbound Setup row still wins over the base's until D11", stays true: the ceremony moves such rows when an operator runs it, and the package-less write that makes new ones stays open until #28576, which is the change that retires that line. No file under `skills/**` teaches the `os migrate` operator ceremonies. ## 维护者速读 - 新增运维命令 `os migrate packageless-overlays`:默认只列出,`--apply`(配 `--yes`)才搬迁;不是启动步骤。 - 搬迁对象:五种覆盖类型中、恰好一个只读代码包发布的同名、未绑定包的行,移入 基础包配对的 `.custom` 包,正文写入 `overrides:{package, basedOn}`。 - 顺序:配对包只经 N-13 的 ensure 门创建;写入经 N-09 的门且保留草稿/生效状态;新行读回为已服务的覆盖之后才删除旧行;可重复运行,按基础包报告。 - 不动:无人发布、多包发布、权限集/岗位、已被配对包覆盖(列出两条出路)的行;缺 `basedOn` 的旧声明会补上 `basedOn`。 - 在 #28576 之前:Setup 不带包名的保存落到配对行时保留该行的 `overrides`,不再被 403 拒绝;不关闭无包写入、不碰 console、不加重定向。 - adopt-orphans 早已跳过发布同名行(#23198),本 PR 加了钉住测试;它的响应是否要点名本命令,见报告中的待决问题。 ## What changes - **`os migrate packageless-overlays`** (`packages/cli/src/commands/migrate/packageless-overlays.ts`), modelled on `os migrate security-catalog-overlays`: lists by default, moves with `--apply` (`--yes` or the prompt), `--json`, `--force`, `--database-url`, and `serve`'s `--preset` / `--dev`. With `--apply` it boots a live `PackageServicePlugin`, so a pair it creates persists to `sys_packages`. Exit 1 only when `--apply` leaves a failed row (or on an error). - **The ceremony** (`packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts`, exposed as `listPackagelessOverlays()` / `migratePackagelessOverlays({ actor })` on the protocol): - **Population.** Every `sys_metadata` row bound to no package, of the five overlay types, active or draft, over a name exactly one installed read-only package ships. - **The move**, per row: 1. The pair is ensured through N-13's ensure door, once per base. 2. The body gains `overrides: { package, basedOn }`, naming the base and its installed `manifest.version`. 3. It is saved into the pair through the save door, so N-09's gate judges it, in the row's own state. 4. The pair's row is read back and must satisfy `isServedOverrideRowOf`. 5. Only then is the unbound row deleted, through the repository addressed by `package_id` NULL, with its history tombstone. A failed read-back keeps the unbound row and reports the row as failed. - **Left alone, and listed.** A name several packages ship (`multi_shipped`), a body declaring another package (`foreign_declaration`), and a name the pair already overrides (`shadowed`). Shadowed rows carry two ways out: delete the package-less row, or "Restore package original" in Studio and re-run. Names no package ships are counted as environment metadata. Permission sets and positions are not overlay types. - **`basedOn` completion.** A pair row declaring `overrides: { package }` with no `basedOn` gains the base's installed version, and is read back. - **Setup keeps working on a moved item** (`saveMetaItemUnaudited`, until #28576). A package-less authoring save whose address is a pair row declaring the override is treated as the write into that pair: - Carrying `overrides`, it is judged as the write into the pair at the `declared_outside_pair` refusal. - Carrying none, it gains the row's declaration at the late `undeclared_same_name` re-judgement. - The landing is read only at those two refusal points. No admitted save reads an extra row, and #5706's lock gate stays the first store reader. - The package-less write is not closed, `ui/**` is not touched, and no Setup redirect is added. - **Census lists.** The one-shot family, the confirmation-exit pin and the JSON-stdout purity roster each gain the new command. The durability gate declares the ceremony's per-row `fail()` report as its failure delivery. The cli vitest config resolves `@objectstack/service-package` to source (`check:test-source-alias`). ## Tests - `packages/objectql/src/protocol-packageless-overlay-migration.test.ts` (10): - THE MOVE (active and draft, pair created, `basedOn` 1.2.0, five reads unchanged, a re-run moves 0). - LEFT ALONE (environment, multi-shipped, shadowed with `shadowDeclares`, an unbound draft beside an active override). - READ BACK FIRST. - `basedOn` completion, over every overridable type; flow and action rows missing `basedOn` are reported failed with the save door's ADR-0146 D9 refusal. - Package-less save with and without `overrides` landing on the pair (draft, publish, publish-mode). - Package-less DELETE restores the base document. - CONTROL: a package-less save carrying `overrides` over an unmoved row is still `403 NOT_OVERRIDABLE`. - adopt-orphans skips the shipped-name row. - `packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts` (3), on a real booted stack: - The preview writes nothing. - `--apply` moves the active and draft rows, persists the pair to `sys_packages`, keeps the controls, serves the same content after a fresh boot, and a re-run moves 0. - `--apply --json` without `--yes` gives `confirmation_required` and moves nothing. - **Red first, on main.** The Wanted 4 cases are red with the real refusals (`undeclared_same_name` without `overrides`, `declared_outside_pair` with them). The other cases are red because the ceremony does not exist. - **Ablations.** Each leg rebuilt `metadata-protocol`'s dist and proved the marker with `ablation-dist-preflight` (objectql resolves it from dist). Each was red as predicted, then restored to the HEAD blob: - shadowed guard → LEFT ALONE red; - read-back check → READ BACK FIRST red; - adopt-orphans `isArtifactBacked` skip → adopt-orphans red; - the early pair-landing judgement (body carrying `overrides`) → that Wanted 4 case red; - the late declaration graft (body carrying none) → both Wanted 4 cases red (each case's last save carries none). ## Acceptance notes - **Wanted 3 (walled postures).** No HTTP door is added: the ceremony runs only from the operator's CLI. The ensure door's walled `403` is unchanged and stays pinned by N-13's own tests. - **Wanted 5.** `reassignOrphanedMetadata` has skipped artifact-backed names since #23198. This PR pins that with a test and ablates it. Naming this ceremony in the adopt-orphans response would change `ReassignOrphanedMetadataResponseSchema` in `packages/spec`, so it is raised on the card as an open question rather than done here. - **Seat 2's reading.** A legacy `overrides: { package }` without `basedOn` gains `basedOn`. - **Delta `5865a0ce` (review S1, S2, N1).** S1: a pair row of the item in either state shadows an unbound row; the bundle member stays in the item identity, so one translation locale does not shadow another. S2: the `basedOn` completion covers every overridable type, and flow and action rows are reported failed with the gate's refusal, so `--apply` exits 1 on a deployment holding one. N1: both types are exported type-only from the index, and the CLI imports them. - **Seat 1's flow finding** (#28413 comment 6559382296) is not covered here: flow is no overlay type (`allowEnvironmentOverlay: false`), a package-less row beside the pair's row is the shadowed shape this ceremony leaves alone, and a declared flow write is refused by N-09 until D9. Routed by the seat. ## Verification - On the delta head `5865a0ce` (os-dev-report 6559681417 on #28413): 86 of 86 derived gates exit 0, `--ran` reconciles; `metadata-protocol` 21944, `objectql` 9913 and cli unit 5166 tests pass, plus 184 targeted; typecheck and lint pass. The readings below were taken on `34c1aa5d`. - Package suites, on `34c1aa5d`: - `@objectstack/metadata-protocol` full suite: 291 files, 21944 tests passed. - `@objectstack/objectql` full suite: 514 files, 9912 tests passed. - `@objectstack/cli` `--project unit`: 5165 of 5166 passed. The one red, `setup-integrations-order.pin`, read a `service-automation` dist built before the rebase onto #28651; it passes after rebuilding that dist. - The cli integration test, `migrate-confirmation-exit.pin`, `one-shot-family`, `package-table` and `security-catalog-overlays`: 184 tests passed. - The `metadata-protocol` suite first caught the lock-gate ordering (#5706 pins). The landing read now sits only at the two refusal points. - Typecheck for `metadata-protocol`, `cli` and `objectql` (with `check:test-typecheck`). - `eslint` over the three packages. - The gates from `dispatch-gates.mjs --commands`, each run with its exit code captured and reconciled with `--ran`. The figures are in the os-dev-report on #28413. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pKMJFQjFx8NCVZb62)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c16040d commit 63acf35

12 files changed

Lines changed: 1947 additions & 2 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(cli,metadata-protocol): `os migrate packageless-overlays` moves package-less Setup customizations of a package's view, dashboard, report, translation or email template into its paired `.custom` package as declared overrides
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
Skills-docs: none — the one taught line this touches, `skills/objectstack-platform/SKILL.md`'s "a package-unbound Setup row still wins over the base's until D11", stays true: the ceremony moves such rows when an operator runs it, and the package-less write that makes new ones stays open until #28576, which is the change that retires that line. No file under `skills/**` teaches the `os migrate` operator ceremonies (a `git grep` of `skills/` for `packageless` and `security-catalog-overlays` names nothing).
11+
12+
**The one operator step of this release.** After upgrading, run `os migrate packageless-overlays` once (preview), then `os migrate packageless-overlays --apply`, on each deployment that has Setup customizations. Until it runs, those customizations are still served, but they stay rows of no package: they are not exported and do not reach another environment.
13+
14+
- **What moves.** Every `sys_metadata` row bound to no package, of the five overlay types (`view`, `dashboard`, `report`, `translation`, `email_template`, active or draft), over a name exactly one installed read-only package ships. It moves into that package's paired `<base>.custom` — created through the ensure door when absent and persisted to `sys_packages` — with `overrides: { package: <base>, basedOn: <the base's installed version> }` in its body, saved through the metadata save door in its own state. The new row is read back as the served override, and only then is the package-less row deleted (with its history tombstone). Every read of the name serves the same content as before. A second run moves nothing.
15+
- **What is left alone, and listed.** A name several packages ship; a row whose body already declares an override of another package; and a row the pair already overrides, active or draft (a customization made in Studio first — the report names the two ways out: delete the package-less row, or "Restore package original" in Studio and re-run). A name no package ships is environment metadata and is not touched. Permission sets and positions are not overlay types; `os migrate security-catalog-overlays` is theirs.
16+
- **Declarations missing `basedOn`.** A row of a pair, of any type a declared override applies to, that declares `overrides: { package }` with no `basedOn` is read as an override but refused on its next save. The same command records `basedOn` (the base's installed version) on it.
17+
- **Setup keeps working on a moved item.** Until the package-less write closes, a save that names no package and lands on a moved row (Setup's form save and reset) keeps that row's `overrides` block when the body carries none, and is judged as the write into the pair when the body carries one — so it is no longer refused `403 NOT_OVERRIDABLE`. A package-less delete removes the pair's row, and the package's own document is served again.
18+
- **Flags.** `--apply` (with `--yes` or the prompt), `--json`, `--force` past a busy SQLite file, `--database-url`, and `serve`'s `--preset` / `--dev`. The command is the operator's; the HTTP door that creates a pair keeps its walled refusal.
19+
20+
`ObjectStackProtocolImplementation` gains `listPackagelessOverlays()` and `migratePackagelessOverlays({ actor })`.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate packageless-overlays` against a real SQLite database: the
5+
* ceremony that moves package-less overlay rows of a shipped name into the
6+
* base's paired `<base>.custom` as declared overrides (ADR-0146 D11, N-16a).
7+
*
8+
* ## The fixture
9+
*
10+
* A compiled artifact (`dist/objectstack.json`, the shape a deployed app boots
11+
* from) whose package ships an object and its list view, over a database an
12+
* older release left behind with, bound to no package:
13+
*
14+
* - MOVED: an active row and a draft row of the package's view;
15+
* - CONTROL, never moved: a view row of a name no package ships (environment
16+
* metadata), and a draft `permission` row over a name the package ships
17+
* (the security catalog's, ADR-0146 D6 — not an overlay type).
18+
*
19+
* ## What each case measures
20+
*
21+
* The command runs in-process through oclif, as the binary runs it. The
22+
* preview writes nothing; `--apply` creates the pair through its door
23+
* (`sys_packages` holds it), moves both rows with the declaration, leaves the
24+
* controls, and a FRESH boot of the same database then serves the moved
25+
* content under the base's name on the package-less read, the read naming the
26+
* base, the layered read and the base's list; a second `--apply` moves 0.
27+
*/
28+
29+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
30+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
31+
import { tmpdir } from 'node:os';
32+
import { dirname, join, resolve } from 'node:path';
33+
import { fileURLToPath } from 'node:url';
34+
import { SqlDriver } from '@objectstack/driver-sql';
35+
import { PackageServicePlugin } from '@objectstack/service-package';
36+
import { isExitSignal } from '../../utils/format.js';
37+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
38+
import MigratePackagelessOverlays from './packageless-overlays.js';
39+
40+
// [#10126] Pay the first transform of these dist-resolved workspace deps at
41+
// MODULE LOAD: the command reaches them through dynamic `import()`s inside
42+
// `run()`, which vitest clocks (`scripts/check-test-source-alias.mjs`).
43+
import '@objectstack/runtime';
44+
import '@objectstack/metadata-protocol';
45+
import '@objectstack/platform-objects/plugin';
46+
47+
const HERE = dirname(fileURLToPath(import.meta.url));
48+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
49+
const CASE_TIMEOUT_MS = 180_000;
50+
51+
const PKG = 'com.example.m28413';
52+
const PAIR = `${PKG}.custom`;
53+
const VIEW = 'm28413_task.default';
54+
const ENV_VIEW = 'm28413_env.default';
55+
const SHIPPED_COLUMNS = ['title', 'status', 'owner'];
56+
const STORED_COLUMNS = ['title', 'owner'];
57+
const DRAFT_COLUMNS = ['title'];
58+
const ARTIFACT = {
59+
manifest: { id: PKG, name: 'm28413', version: '1.4.0', type: 'app', namespace: 'm28413' },
60+
objects: [{
61+
name: 'm28413_task',
62+
label: 'Task',
63+
sharingModel: 'private',
64+
fields: Object.fromEntries(SHIPPED_COLUMNS.map((f) => [f, { name: f, label: f, type: 'text' }])),
65+
}],
66+
views: [{
67+
object: 'm28413_task',
68+
list: { label: 'All', type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: SHIPPED_COLUMNS.map((field) => ({ field })) },
69+
}],
70+
permissions: [{ name: 'm28413_rep', label: 'Rep', objects: {} }],
71+
};
72+
73+
const viewBody = (name: string, columns: readonly string[]) => ({
74+
name,
75+
object: 'm28413_task',
76+
viewKind: 'list',
77+
label: 'Saved in Setup',
78+
config: { type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: columns.map((field) => ({ field })) },
79+
});
80+
81+
interface SeedRow { type: string; name: string; state: string; body: Record<string, unknown> }
82+
const SEED: SeedRow[] = [
83+
{ type: 'view', name: VIEW, state: 'active', body: viewBody(VIEW, STORED_COLUMNS) },
84+
{ type: 'view', name: VIEW, state: 'draft', body: viewBody(VIEW, DRAFT_COLUMNS) },
85+
// Controls.
86+
{ type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) },
87+
// A DRAFT: an active one over a package's name refuses the cold boot, which
88+
// is \`os migrate security-catalog-overlays\`' to clear, not this ceremony's.
89+
{ type: 'permission', name: 'm28413_rep', state: 'draft', body: { name: 'm28413_rep', label: 'Saved in the environment', objects: {} } },
90+
];
91+
92+
let dir: string;
93+
let templateDb: string;
94+
const savedCwd = process.cwd();
95+
const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV',
96+
'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID'] as const;
97+
const savedEnv: Record<string, string | undefined> = {};
98+
99+
let caseSeq = 0;
100+
function caseDb(): string {
101+
caseSeq += 1;
102+
const file = join(dir, 'cases', `${caseSeq}.db`);
103+
mkdirSync(dirname(file), { recursive: true });
104+
copyFileSync(templateDb, file);
105+
return file;
106+
}
107+
108+
/** Every `sys_metadata` row, `type/name@binding/state`, read on a connection of our own. */
109+
async function storedRows(dbFile: string): Promise<string[]> {
110+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true });
111+
try {
112+
const rows = await (driver as any).knex('sys_metadata').select('type', 'name', 'package_id', 'state');
113+
return rows.map((r: any) => `${r.type}/${r.name}@${r.package_id ?? '-'}/${r.state}`).sort();
114+
} finally {
115+
await driver.disconnect();
116+
}
117+
}
118+
119+
/** One stored body, parsed. */
120+
async function storedBody(dbFile: string, where: Record<string, unknown>): Promise<any> {
121+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true });
122+
try {
123+
const row = await (driver as any).knex('sys_metadata').where(where).first();
124+
return row ? JSON.parse(row.metadata) : undefined;
125+
} finally {
126+
await driver.disconnect();
127+
}
128+
}
129+
130+
/** The `sys_packages` ids stored. */
131+
async function storedPackages(dbFile: string): Promise<string[]> {
132+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true });
133+
try {
134+
const rows = await (driver as any).knex('sys_packages').select('id');
135+
return rows.map((r: any) => String(r.id)).sort();
136+
} finally {
137+
await driver.disconnect();
138+
}
139+
}
140+
141+
interface RunResult { payload: any; exitCode: number }
142+
143+
/** Run the command through oclif and capture its one JSON document. */
144+
async function runJson(argv: string[]): Promise<RunResult> {
145+
const savedExit = process.exitCode;
146+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
147+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
148+
if (cb) cb();
149+
return true;
150+
}) as typeof process.stdout.write;
151+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
152+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
153+
vi.spyOn(console, 'log').mockImplementation(() => {});
154+
vi.spyOn(console, 'warn').mockImplementation(() => {});
155+
vi.spyOn(console, 'error').mockImplementation(() => {});
156+
let thrownExit: number | undefined;
157+
try {
158+
try {
159+
await MigratePackagelessOverlays.run(argv, { root: CLI_ROOT });
160+
} catch (error) {
161+
if (!isExitSignal(error)) throw error;
162+
thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
163+
}
164+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
165+
let payload: any;
166+
try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; }
167+
return { payload, exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
168+
} finally {
169+
process.exitCode = savedExit;
170+
vi.restoreAllMocks();
171+
}
172+
}
173+
174+
const columnsOf = (item: any): string[] => (item?.config?.columns ?? item?.columns ?? []).map((c: any) => c?.field ?? c);
175+
176+
/** What a fresh boot of `dbFile` serves for `VIEW`: the package-less read, the read naming the base, the layered effective, the base's list. */
177+
async function servedColumns(dbFile: string): Promise<string[][]> {
178+
const stack = await bootSchemaStack({
179+
jsonOutput: false, databaseUrl: `file:${dbFile}`, projectRoot: dir, composeHostStack: true,
180+
// The package registry `os serve` composes, so the pair installs from `sys_packages` as it does there.
181+
extraPlugins: [new PackageServicePlugin()],
182+
});
183+
try {
184+
const protocol = stack.kernel.getService('protocol') as any;
185+
const read = async (packageId?: string) =>
186+
(await protocol.getMetaItem({ type: 'view', name: VIEW, ...(packageId ? { packageId } : {}) }))?.item;
187+
const layered = await protocol.getMetaItemLayered({ type: 'view', name: VIEW });
188+
const list = await protocol.getMetaItems({ type: 'view', packageId: PKG });
189+
return [
190+
columnsOf(await read()),
191+
columnsOf(await read(PKG)),
192+
columnsOf(layered?.effective),
193+
...((list?.items ?? []) as any[]).filter((i) => i?.name === VIEW).map((i) => columnsOf(i)),
194+
];
195+
} finally {
196+
await stack.shutdown();
197+
}
198+
}
199+
200+
beforeAll(async () => {
201+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
202+
for (const key of ENV_KEYS) delete process.env[key];
203+
process.env.NODE_ENV = 'production';
204+
dir = mkdtempSync(join(tmpdir(), 'os-28413-'));
205+
mkdirSync(join(dir, 'dist'), { recursive: true });
206+
process.env.OS_HOME = join(dir, 'home');
207+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
208+
writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify(ARTIFACT));
209+
210+
// The database an older release left: tables provisioned, then the
211+
// package-less rows written at the driver the way that release stored them.
212+
templateDb = join(dir, 'template.db');
213+
const seed = await bootSchemaStack({
214+
jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: dir, hydrateMetadata: false,
215+
extraPlugins: [new PackageServicePlugin()],
216+
});
217+
await seed.shutdown();
218+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: templateDb }, useNullAsDefault: true });
219+
try {
220+
const k = (driver as any).knex;
221+
const now = new Date().toISOString();
222+
let n = 0;
223+
for (const row of SEED) {
224+
n += 1;
225+
await k('sys_metadata').insert({
226+
id: `seed_${n}`, type: row.type, name: row.name, package_id: null, state: row.state,
227+
version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(row.body),
228+
});
229+
}
230+
} finally {
231+
await driver.disconnect();
232+
}
233+
}, CASE_TIMEOUT_MS);
234+
235+
afterAll(() => {
236+
process.chdir(savedCwd);
237+
for (const [key, value] of Object.entries(savedEnv)) {
238+
if (value === undefined) delete process.env[key];
239+
else process.env[key] = value;
240+
}
241+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
242+
});
243+
244+
// The command takes `process.cwd()` as the project root, as a real invocation does.
245+
beforeEach(() => { process.chdir(dir); });
246+
afterEach(() => { process.chdir(savedCwd); });
247+
248+
describe('os migrate packageless-overlays', () => {
249+
it('the preview lists the shipped name\'s rows to move, counts the environment row, and writes nothing', async () => {
250+
const db = caseDb();
251+
const before = await storedRows(db);
252+
const { payload, exitCode } = await runJson(['--database-url', `file:${db}`, '--json']);
253+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
254+
expect(exitCode).toBe(0);
255+
expect(payload.apply).toBe(false);
256+
expect(payload.rows.map((r: any) => `${r.type}/${r.name}/${r.state} ${r.disposition} → ${r.pair}`)).toEqual([
257+
`view/${VIEW}/active movable → ${PAIR}`,
258+
`view/${VIEW}/draft movable → ${PAIR}`,
259+
]);
260+
expect(payload.environmentRows).toBe(1);
261+
expect(await storedRows(db)).toEqual(before);
262+
expect(await storedPackages(db)).not.toContain(PAIR);
263+
}, CASE_TIMEOUT_MS);
264+
265+
it('--apply moves both rows into the pair with the declaration, keeps the controls, serves the same content, and a re-run moves 0', async () => {
266+
const db = caseDb();
267+
const served = await servedColumns(db);
268+
expect(served).toEqual([STORED_COLUMNS, STORED_COLUMNS, STORED_COLUMNS, STORED_COLUMNS]);
269+
270+
const { payload, exitCode } = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']);
271+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
272+
expect(exitCode).toBe(0);
273+
expect(payload.moved).toBe(2);
274+
expect(payload.failed).toBe(0);
275+
expect(payload.bases).toEqual([{ base: PKG, pair: PAIR, pairCreated: true, moved: 2, declarationsCompleted: 0, failed: 0, shadowed: 0 }]);
276+
expect(payload.rows.map((r: any) => `${r.state} ${r.outcome} ${r.packageId} ${r.basedOn}`)).toEqual([
277+
`active moved ${PAIR} 1.4.0`,
278+
`draft moved ${PAIR} 1.4.0`,
279+
]);
280+
281+
expect(await storedRows(db)).toEqual([
282+
`permission/m28413_rep@-/draft`,
283+
`view/${ENV_VIEW}@-/active`,
284+
`view/${VIEW}@${PAIR}/active`,
285+
`view/${VIEW}@${PAIR}/draft`,
286+
]);
287+
for (const state of ['active', 'draft']) {
288+
const body = await storedBody(db, { type: 'view', name: VIEW, package_id: PAIR, state });
289+
expect(body.overrides, state).toEqual({ package: PKG, basedOn: '1.4.0' });
290+
}
291+
expect(columnsOf(await storedBody(db, { type: 'view', name: VIEW, package_id: PAIR, state: 'draft' }))).toEqual(DRAFT_COLUMNS);
292+
// The pair came through its door, so it outlives this process.
293+
expect(await storedPackages(db)).toContain(PAIR);
294+
295+
// A fresh boot serves the moved content under the base's name.
296+
expect(await servedColumns(db)).toEqual(served);
297+
298+
const again = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']);
299+
expect(again.payload.error, JSON.stringify(again.payload).slice(0, 600)).toBeUndefined();
300+
expect(again.exitCode).toBe(0);
301+
expect(again.payload.moved).toBe(0);
302+
expect(again.payload.rows).toEqual([]);
303+
}, CASE_TIMEOUT_MS * 3);
304+
305+
it('--apply without --yes in --json refuses and moves nothing', async () => {
306+
const db = caseDb();
307+
const before = await storedRows(db);
308+
const { payload, exitCode } = await runJson(['--apply', '--database-url', `file:${db}`, '--json']);
309+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBe('confirmation_required');
310+
expect(exitCode).toBe(1);
311+
expect(await storedRows(db)).toEqual(before);
312+
}, CASE_TIMEOUT_MS);
313+
});

0 commit comments

Comments
 (0)