Skip to content

Commit 1e9f72d

Browse files
fix(cli): os migrate organization-ownership --apply carries a public-form withdrawal onto a read-only package view into its .custom pair, declared (#29508)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9BYq3eGXavUMZr5cccFBPca)_ Fixes #29396 Clause-②: no Metadata-protocol: none Measured on the real command (table under Why): the cli now completes a step it used to stop on. No door, close predicate or accepted request shape changes; the diff touches only `packages/cli`. ## 维护者速读 - **改了什么**:`os migrate organization-ownership --apply` 把组织撤回的公开表单带到"只读代码包"的视图上时,不再停下报阻塞,而是先确保该包的 `.custom` 配对包存在,再把撤回存进配对包,并声明 `overrides: { package: 基础包, basedOn: 基础包当前版本 }`。 - **为什么改**:只读基础包本身不再接受定制(上一轮关闭了以基础包名义的写入),仪式因此停在阻塞上,操作员要手工把撤回存进配对包再重跑。现在仪式自己完成这一步。 - **风险与回滚**:可写包的视图照旧原地保存。配对包确保不了、或门拒绝写入配对包时,仍走原来以包名义的保存:门放行就照旧带过去,门拒绝就照旧阻塞,阻塞文案多带一句配对包的拒绝原因。没有改任何门、错误码或元数据格式。回滚 = revert 本 PR。 - **先看**:`packages/cli/src/utils/organization-ownership-apply.ts` 的 `carryIntoPair` 和 `carryFormWithdrawals` 里新加的分支;`packages/cli/src/commands/migrate/organization-ownership.ts` 新的 `pairs:` 接线;`packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts`,预期 1 个通过:撤回进了 `com.example.wdpair.custom`,声明了 `basedOn: 1.4.0`,重新启动后匿名表单入口不再提供该表单。 - **需要你做的**:无,席位落地。 ## What changes - **`carryFormWithdrawals`** (`packages/cli/src/utils/organization-ownership-apply.ts`). For a served view whose package is read-only, the carry now goes through `carryIntoPair`: it ensures the base's `.custom` pair, then saves the withdrawn view into the pair through the metadata door, declaring `overrides: { package: BASE, basedOn }`. A body that already declares the base (the pair's own override, as served) keeps its declaration. The withdrawal counts in `summary.withdrawalsCarried`, and the progress line names the pair and the version. - **A writable package's view** is saved in place, as before; no pair is ensured. A package-less view is untouched by the new branch. The `single` skip for a view whose key an organization row holds is unchanged. - **When the pair cannot take it** (the pair cannot be ensured, the writability read throws, or the door refuses the declared save), the carry makes the save naming the package as before. Where the door admits that save, it carries the withdrawal as before. Where it refuses, the step blocks with the same sentence as before, plus one clause naming the pair's refusal (`Nor could its customization package take it: ...`). - **New port** `CustomizationPairPort` (`readOnly`, `ensure`), optional on `ApplyOptions` as `pairs`. Absent, the carry behaves exactly as on main. `protocolCustomizationPairs(...)` builds it from the booted stack. - **The command wiring** (`packages/cli/src/commands/migrate/organization-ownership.ts`): `readOnly` is `isWritablePackage(engine, id)` from `@objectstack/metadata-protocol`, negated. That is the predicate the save door judges by, not a second reading. `ensure` is the protocol's in-process `ensureCustomizationPackage` (the body of `POST /packages/:id/ensure-customization`). `basedOn` is the installed base's `manifest.version`, the value the named-base close and `os migrate packageless-overlays` read too. - **Changeset** `.changeset/29396-withdrawal-carry-into-pair.md` (`@objectstack/cli` patch). It says the manual step in the pending named-base changeset is no longer needed when the pair can be ensured. That changeset is not edited. ## Tests - **New integration file** `packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts`. It runs the real command in process through oclif, plan then `--apply`, over a database a served boot of this tree provisioned. The project's config is a booted code package (read-only) shipping a public form view. An organization's row of that view withdraws the form, and `sys_metadata` carries `organization_id` as on v17. The test pins four things. The step carries 1. The pair's row declares `{ package: BASE, basedOn: '1.4.0' }` with `enabled: false`. No row is bound to the base. A fresh boot (with the package service, so the pair installs from `sys_packages`) serves the base's form withdrawn, and the env-wide list the anonymous doors judge by serves the slug from no view. A second `--apply` finds the step checkpointed and the stored rows unchanged. - **Five new cases** in `packages/cli/src/utils/organization-ownership-apply.test.ts`, in a nested describe: - ⭐ The read-only view is carried into the pair, declared at the base's version. The doors serve it withdrawn, and a re-run with no checkpoint carries nothing. - A writable package's view is saved in place, and no pair is ensured. - A pair that cannot be ensured keeps the blocker, which names both refusals, and nothing is written. - A refused declared save into the pair keeps the blocker, naming that refusal. - Control: where the pair cannot take it and the door admits the save naming the package, that save carries it. - Forced test edits: none. The test file's `apply(...)` helper gained two pass-through options (`pairs`, `onProgress`), and no existing assertion changed. ## Why **Step 0, measured on the real command** (the integration file above). The main column is `aca58cdb` with this branch's test file only. The head column is this branch. | Reading | main `aca58cdb` | head | |---|---|---| | Precondition: the shipped form is served open | yes (`wdp_lead.intake` of `com.example.wdpair`) | yes | | `metadata-promotion` | `blocked`, `withdrawalsCarried: 0` | `done`, `withdrawalsCarried: 1` | | Ceremony | `complete: false`; the 10 later steps `not-reached` | `complete: true`, `blocking: 0` | | Stored rows of the view | the promoted organization row (no package) only | that row, plus `com.example.wdpair.custom` declaring `overrides: { package: 'com.example.wdpair', basedOn: '1.4.0' }`, `enabled: false` | | Row bound to the read-only base | none | none | | Fresh boot: views serving the slug open | not measured (the case stops at the blocker) | none | The blocker on main, verbatim: > An organization withdrew the public form(s) wdp-intake of the view wdp_lead.intake (package com.example.wdpair), and the metadata door refused to carry that withdrawal onto the environment: 'view/wdp_lead.intake' ships in 'com.example.wdpair': save it into 'com.example.wdpair.custom' (?package=com.example.wdpair.custom), declaring overrides: { package: 'com.example.wdpair', basedOn }. The read-only base itself takes no customization (ADR-0146 D1, D11): a row bound to it from before is served until the pair declares the override, or a delete naming the base removes it.. Withdraw the form there (sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form (ruling record 6020151485). **How the cli reaches the pair** (read on main): | Question | Answer | |---|---| | Can the ceremony's door (`run.opts.door`) ensure the pair? | No. It is `protocol.saveMetaItem` alone. The protocol service the command already holds exposes `ensureCustomizationPackage` in process, the body of `POST /packages/:id/ensure-customization`, so the command wires it as a second port. | | How is the override declared? | In the saved body: `overrides: { package: BASE, basedOn: VERSION }`, saved naming `BASE.custom` (the dogfood helpers `ensurePair` / `declaredOverride` have the same shape). | | Where does `basedOn` come from? | The installed base's `manifest.version` from the engine registry: the value the named-base close checks and `os migrate packageless-overlays` records. | | Which packages are read-only? | `isWritablePackage(engine, id)`, the save door's own predicate (ADR-0070 D2): a booted code package or a `system` / `cloud` scope. | ## Acceptance notes - When the pair cannot take the withdrawal, the carry falls back to the save naming the package. That is main's behaviour, kept on purpose. The door's named-base close refuses only where the pair can take the write, so where the pair cannot (no namespace, no version, an existing pair lacking the dependency), the door still admits the save, as on main. The card's "keep today's blocker" holds wherever the door refuses. - Observed, unchanged: under `single`, the organization's package-less row of the view is promoted in place (`promoted: 1`), so the environment keeps a package-less row of a shipped name beside the pair's override. Both have the form withdrawn. Moving such rows into the pair is `os migrate packageless-overlays`' job, not this step's. ## Verification All readings are on head `986b2d01` (this branch with `origin/main` merged) unless named. Heavy runs went through `scripts/pm/os-verify-lock.sh`. <details><summary>Tests: 123/123 in the touched files, cli unit tier 374/374, ceremony dogfood files 34/34</summary> - `pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts src/utils/organization-ownership-apply.test.ts src/utils/organization-ownership-apply.served-catalog.integration.test.ts`: Test Files 3 passed (3), Tests 123 passed (123), VERDICT command-exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: Test Files 372 passed, 2 failed (374). Both failures were prerequisites, not results: `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts` said "packages/cli is not built (./dist/index.js is absent)". After the cli was built, those two: Test Files 2 passed (2), Tests 29 passed (29). - The dogfood files that run this ceremony or its form withdrawal, after `turbo run build --filter='@objectstack/dogfood^...'`: `upgrade-18x-harness`, `organization-ownership-boot-refusal-order`, `upgrade-18x-index-equivalence` and `showcase-public-form-withdrawal-layers`. Test Files 4 passed (4), Tests 34 passed (34), VERDICT command-exit 0. The full battery was not run: the title has no `!` and there is no narrowing arm. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (tsc, plus check:test-typecheck OK with debt held). - `pnpm lint` scoped to the package (`eslint packages/cli --no-inline-config`): exit 0. </details> <details><summary>Ablations: the pair branch, and the writability read</summary> Each was planted with `scripts/ablation-replace.mjs` (anchor hit 1 time, blob `b629ad6b6c29` changed, restored to `b629ad6b6c29`), then `git diff HEAD` was empty (0 bytes) and `git status` clean. The cli tests import `src` directly, so no dist leg is needed. 1. The pair branch switched off (`if (packageId !== null && run.opts.pairs && false)`), which puts back main's save naming the base. 4 failed / 114 passed. The unit cases: the read-only carry (`expected [] to deeply equal [ 'pkg_a' ]`, no pair ensured), the unensurable-pair blocker and the refused-pair blocker (each blocker lost its pair clause). The real-command integration case failed too. 2. The writability read ignored (every package treated as read-only). The writable case went red: the save went into `pkg_a.custom` with a declaration instead of `pkg_a` in place. </details> <details><summary>Derived gates: 62 run, 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `986b2d01` derived 62 commands. All were run, and `--ran` reconciled: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. Five answered exit 3 (prerequisite not met) on the first pass and exit 0 when re-run once the prerequisite was in place: - `check-plugin-teardown-shape --self-test`: its pinned fixture commit was fetched. - `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-walk-parity`: the cli dist was built (the dogfood closure). - `check:i18n-coverage`: packages and examples were built. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on `packages/cli`, exit 0). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the maintainer's Chinese brief). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQTt48dSV5oqZ5jUhK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aee9c47 commit 1e9f72d

5 files changed

Lines changed: 517 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate organization-ownership --apply` carries an organization's public-form withdrawal onto a read-only package's view into that package's `.custom` pair, declared, instead of stopping
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The ceremony carries every organization-layer public-form withdrawal onto the environment (`metadata-promotion`). On a view a read-only package ships, that save named the package itself, which the metadata door now refuses (ADR-0146 D1, D11: the read-only base takes no customization), so the ceremony stopped with a blocker and the operator had to save the withdrawal into the pair by hand.
11+
12+
- **Read-only package.** The carry now ensures the package's `.custom` pair through the ensure door (the in-process `POST /packages/:id/ensure-customization`), then saves the withdrawn view into the pair declaring `overrides: { package: <base>, basedOn: <the installed base's version> }`. Every read of the base's name serves that override, so the form stays withdrawn. It counts in `withdrawalsCarried`, and the progress line names the pair. The manual step in the changeset that closed the write naming the base (save the withdrawal into the pair, then re-run) is no longer needed when the pair can be ensured.
13+
- **Writable package.** Its view is still saved in place, as before.
14+
- **Pair not available.** When the pair cannot be ensured, or the door refuses the declared save into it, the carry makes the save naming the package as before: where the door admits it, that save carries the withdrawal; where it refuses, the step blocks as before, and the blocker now also names the pair's refusal.
15+
- A re-run carries nothing more: the served view already has the form withdrawn.
16+
17+
No door, no accepted request shape and no error code changes.

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import {
4242
applyOrganizationOwnership,
4343
checkpointFromDetails,
44+
protocolCustomizationPairs,
4445
MetadataChoiceRefusal,
4546
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
4647
judgeDeclaredSharingRules,
@@ -751,7 +752,7 @@ export default class OrganizationOwnershipCeremony extends Command {
751752
// The dialect, once, before the first statement: an unresolved one is
752753
// refused here, naming the driver, and never read as SQLite.
753754
const dialect = ceremonyDialectOf(stack.driver);
754-
const { normalizeRows } = await import('@objectstack/metadata-protocol');
755+
const { isWritablePackage, normalizeRows } = await import('@objectstack/metadata-protocol');
755756
// [#23816] A boot that skipped schema sync measured no table absent, so
756757
// the ledger's presence is read from the catalog instead.
757758
const ledgerAbsent = stack.schemaSync === 'skipped'
@@ -774,6 +775,7 @@ export default class OrganizationOwnershipCeremony extends Command {
774775
getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>;
775776
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
776777
getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise<unknown>;
778+
ensureCustomizationPackage?: (request: { basePackageId: string }) => Promise<{ packageId: string }>;
777779
} | undefined;
778780
const catalog = createSecurityCatalogReader({
779781
registry: engine.registry as never,
@@ -926,6 +928,21 @@ export default class OrganizationOwnershipCeremony extends Command {
926928
};
927929
},
928930
},
931+
// [ADR-0146 D1, D3] A withdrawal onto a read-only package's view goes
932+
// into its `.custom` pair: the save door's own writability predicate,
933+
// and the ensure door, in process.
934+
pairs: protocolCustomizationPairs({
935+
ensureCustomizationPackage: async (request) => {
936+
if (typeof protocol?.ensureCustomizationPackage !== 'function') {
937+
throw new Error('no metadata protocol is composed on this stack');
938+
}
939+
return protocol.ensureCustomizationPackage(request);
940+
},
941+
isWritablePackage: (packageId) => isWritablePackage(engine, packageId),
942+
baseVersion: (packageId) =>
943+
(engine.registry as { getPackage?(id: string): { manifest?: { version?: unknown } } | undefined } | undefined)
944+
?.getPackage?.(packageId)?.manifest?.version,
945+
}),
929946
views: {
930947
// The env-wide view list, exactly as the anonymous form doors read it.
931948
list: async () => {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership --apply`, the form-withdrawal carry onto
5+
* a view a READ-ONLY package ships (ADR-0146 D1, D2, D3).
6+
*
7+
* The read-only base itself takes no customization: the metadata door refuses
8+
* a save naming it where its `.custom` pair can take the write (the named-base
9+
* close). So the carry ensures the pair through the ensure door and saves the
10+
* withdrawal into it, declaring `overrides: { package: <base>, basedOn }`.
11+
*
12+
* ## The fixture
13+
*
14+
* A project whose config is a booted code package (read-only: its manifest is
15+
* registered at boot) shipping an object and a public form view, over a
16+
* database a served boot of this tree left behind, shaped like v17:
17+
* `sys_metadata` carries `organization_id`, and an organization's row of the
18+
* form view withdraws it (`sharing.enabled: false`).
19+
*
20+
* ## What each case measures
21+
*
22+
* The real command, in process through oclif (plan, then `--apply`): the step
23+
* carries the withdrawal into the pair, the pair is in `sys_packages`, its row
24+
* declares the override at the base's version, and the env-wide view list the
25+
* anonymous doors judge by serves the form withdrawn. A second `--apply` over
26+
* the same database finds the step checkpointed and writes nothing. (A re-run
27+
* of the carry itself, with no checkpoint, is pinned in
28+
* `organization-ownership-apply.test.ts`.)
29+
*/
30+
31+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
32+
import Database from 'better-sqlite3';
33+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
34+
import { randomBytes } from 'node:crypto';
35+
import { tmpdir } from 'node:os';
36+
import { dirname, join, resolve } from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core';
39+
import { isExitSignal } from '../../utils/format.js';
40+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
41+
import MigrateOrganizationOwnership from './organization-ownership.js';
42+
43+
// [#10126] Pay the first load of what the command reaches through dynamic
44+
// `import()`s at MODULE LOAD, outside the clocked case.
45+
import '@objectstack/runtime';
46+
import '@objectstack/objectql';
47+
import '@objectstack/metadata-protocol';
48+
import { PackageServicePlugin } from '@objectstack/service-package';
49+
50+
const HERE = dirname(fileURLToPath(import.meta.url));
51+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
52+
const CASE_TIMEOUT_MS = 240_000;
53+
54+
const OVERRIDING_ENV = [
55+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH',
56+
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'OS_MULTI_ORG_ENABLED',
57+
] as const;
58+
59+
const BASE = 'com.example.wdpair';
60+
const PAIR = `${BASE}.custom`;
61+
const VERSION = '1.4.0';
62+
const VIEW = 'wdp_lead.intake';
63+
const SLUG = 'wdp-intake';
64+
const DATA = { provider: 'object', object: 'wdp_lead' };
65+
const FORM = {
66+
type: 'simple',
67+
data: DATA,
68+
sections: [{ name: 'main', label: 'Main', columns: 1, fields: [{ field: 'name' }] }],
69+
sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` },
70+
};
71+
const CONFIG = {
72+
manifest: { id: BASE, name: 'Withdrawal Pair', version: VERSION, type: 'app', namespace: 'wdp' },
73+
objects: [{ name: 'wdp_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }],
74+
views: [{ list: { label: 'Leads', type: 'grid', data: DATA, columns: [{ field: 'name' }] }, formViews: { intake: FORM } }],
75+
};
76+
/** The organization's 17.x row of the form view: the served body, the form withdrawn. */
77+
const WITHDRAWN = {
78+
name: VIEW,
79+
object: 'wdp_lead',
80+
viewKind: 'form',
81+
label: 'Intake',
82+
config: { ...FORM, sharing: { ...FORM.sharing, enabled: false } },
83+
};
84+
85+
let dir: string;
86+
let projectDir: string;
87+
let templateDb: string;
88+
const savedEnv: Record<string, string | undefined> = {};
89+
const savedCwd = process.cwd();
90+
91+
/** The real command over `dbFile`: the plan run first (its file is the reviewed plan), then `--apply`. */
92+
async function applyOver(dbFile: string, tag: string): Promise<{ report: any; exitCode: number }> {
93+
const savedExit = process.exitCode;
94+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
95+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
96+
if (cb) cb();
97+
return true;
98+
}) as typeof process.stdout.write;
99+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
100+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
101+
vi.spyOn(console, 'log').mockImplementation(() => {});
102+
vi.spyOn(console, 'warn').mockImplementation(() => {});
103+
vi.spyOn(console, 'error').mockImplementation(() => {});
104+
process.chdir(projectDir);
105+
let exitCode: number | undefined;
106+
try {
107+
const planFile = join(projectDir, `plan-${tag}.json`);
108+
await MigrateOrganizationOwnership.run(['--out', planFile, '--database-url', `file:${dbFile}`, '--json'], { root: CLI_ROOT });
109+
stdout.mockClear();
110+
try {
111+
await MigrateOrganizationOwnership.run(
112+
[
113+
'--apply', '--backup-taken', '--plan-file', planFile,
114+
'--out', join(projectDir, `apply-${tag}.json`), '--database-url', `file:${dbFile}`, '--json',
115+
],
116+
{ root: CLI_ROOT },
117+
);
118+
} catch (error) {
119+
if (!isExitSignal(error)) throw error;
120+
exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
121+
}
122+
const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
123+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
124+
return { report: payload.report, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
125+
} finally {
126+
process.chdir(savedCwd);
127+
process.exitCode = savedExit;
128+
vi.restoreAllMocks();
129+
}
130+
}
131+
132+
const promotion = (report: any): { status: string; summary: Record<string, number>; blockers: string[] } =>
133+
report.steps.find((s: { step: string }) => s.step === 'metadata-promotion');
134+
135+
/** The stored `view` rows of the form, `package/state`, with the declaration each body carries. */
136+
function storedForm(dbFile: string): Array<{ pkg: string; enabled: unknown; overrides: unknown }> {
137+
const db = new Database(dbFile, { readonly: true });
138+
try {
139+
return (db.prepare(`SELECT package_id, metadata FROM sys_metadata WHERE type = 'view' AND name = ? ORDER BY package_id`)
140+
.all(VIEW) as Array<{ package_id: string | null; metadata: string }>)
141+
.map((row) => {
142+
const body = JSON.parse(row.metadata) as { config?: { sharing?: { enabled?: unknown } }; overrides?: unknown };
143+
return { pkg: row.package_id ?? '-', enabled: body.config?.sharing?.enabled, overrides: body.overrides };
144+
});
145+
} finally {
146+
db.close();
147+
}
148+
}
149+
150+
/** On a fresh boot of `dbFile`: the base's served form, and the views the anonymous doors serve the slug from. */
151+
async function served(dbFile: string): Promise<{ base: unknown; open: string[] }> {
152+
process.chdir(projectDir);
153+
const stack = await bootSchemaStack({
154+
jsonOutput: false, databaseUrl: `file:${dbFile}`, projectRoot: projectDir, composeHostStack: true,
155+
// The package registry `os serve` composes, so the pair installs from `sys_packages` as it does there.
156+
extraPlugins: [new PackageServicePlugin()],
157+
});
158+
try {
159+
const protocol = stack.kernel.getService('protocol') as any;
160+
const base = (await protocol.getMetaItem({ type: 'view', name: VIEW, packageId: BASE }))?.item;
161+
const listed = await protocol.getMetaItems({ type: 'view' });
162+
const list = ((Array.isArray(listed) ? listed : listed?.items) ?? []) as Array<Record<string, unknown>>;
163+
const open = list
164+
.filter((v) => anonymousFormIntakeCandidates(v).some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(list, v, c)))
165+
.map((v) => `${String(v.name)}|${String(v._packageId ?? '')}`);
166+
return { base: (base as { config?: { sharing?: unknown } } | undefined)?.config?.sharing, open };
167+
} finally {
168+
await stack.shutdown();
169+
process.chdir(savedCwd);
170+
}
171+
}
172+
173+
beforeAll(async () => {
174+
for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
175+
for (const key of OVERRIDING_ENV) delete process.env[key];
176+
process.env.NODE_ENV = 'production';
177+
process.env.OS_SECRET_KEY = randomBytes(32).toString('hex');
178+
179+
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-withdrawal-pair-'));
180+
projectDir = join(dir, 'project');
181+
mkdirSync(projectDir, { recursive: true });
182+
writeFileSync(join(projectDir, 'objectstack.config.mjs'), `export default ${JSON.stringify(CONFIG, null, 2)};\n`);
183+
184+
// A served boot of this tree provisions the tables; then the v17 shape.
185+
templateDb = join(dir, 'template.db');
186+
process.chdir(projectDir);
187+
try {
188+
const seed = await bootSchemaStack({
189+
jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: projectDir, composeHostStack: true,
190+
extraPlugins: [new PackageServicePlugin()],
191+
});
192+
await seed.shutdown();
193+
} finally {
194+
process.chdir(savedCwd);
195+
}
196+
const db = new Database(templateDb);
197+
try {
198+
db.exec(`ALTER TABLE "sys_metadata" ADD COLUMN "organization_id" varchar(255)`);
199+
const now = new Date().toISOString();
200+
db.prepare(
201+
`INSERT INTO sys_metadata (id, organization_id, type, name, package_id, state, version, created_at, updated_at, metadata) ` +
202+
`VALUES ('md_org', 'org_x', 'view', ?, NULL, 'active', 1, ?, ?, ?)`,
203+
).run(VIEW, now, now, JSON.stringify(WITHDRAWN));
204+
} finally {
205+
db.close();
206+
}
207+
}, CASE_TIMEOUT_MS);
208+
209+
afterAll(() => {
210+
process.chdir(savedCwd);
211+
for (const [key, value] of Object.entries(savedEnv)) {
212+
if (value === undefined) delete process.env[key];
213+
else process.env[key] = value;
214+
}
215+
rmSync(dir, { recursive: true, force: true });
216+
});
217+
218+
describe('organization-ownership --apply: a withdrawal onto a read-only package\'s view goes into its .custom pair', () => {
219+
it('⭐ carried into the pair, declared as the override of the base; the served form is withdrawn; a re-run carries nothing', async () => {
220+
const dbFile = join(dir, 'case.db');
221+
copyFileSync(templateDb, dbFile);
222+
expect((await served(dbFile)).open, 'PRECONDITION: the shipped form is served open').toEqual([`${VIEW}|${BASE}`]);
223+
224+
const { report } = await applyOver(dbFile, 'first');
225+
const step = promotion(report);
226+
expect(step.blockers.filter((b) => b.includes(VIEW))).toEqual([]);
227+
expect(step.summary.withdrawalsCarried).toBe(1);
228+
expect(storedForm(dbFile).filter((r) => r.pkg === PAIR)).toEqual([
229+
{ pkg: PAIR, enabled: false, overrides: { package: BASE, basedOn: VERSION } },
230+
]);
231+
// ⛔ Nothing bound to the read-only base itself.
232+
expect(storedForm(dbFile).filter((r) => r.pkg === BASE)).toEqual([]);
233+
234+
const after = await served(dbFile);
235+
expect(after.base).toMatchObject({ enabled: false });
236+
expect(after.open).toEqual([]);
237+
238+
// A re-run finds the step checkpointed and writes nothing more.
239+
const rows = storedForm(dbFile);
240+
const again = await applyOver(dbFile, 'again');
241+
expect(promotion(again.report).status).toBe('checkpointed');
242+
expect(storedForm(dbFile)).toEqual(rows);
243+
}, CASE_TIMEOUT_MS);
244+
});

0 commit comments

Comments
 (0)