diff --git a/.changeset/29396-withdrawal-carry-into-pair.md b/.changeset/29396-withdrawal-carry-into-pair.md new file mode 100644 index 00000000000..2385bdce165 --- /dev/null +++ b/.changeset/29396-withdrawal-carry-into-pair.md @@ -0,0 +1,17 @@ +--- +'@objectstack/cli': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +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. + +- **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: , basedOn: }`. 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. +- **Writable package.** Its view is still saved in place, as before. +- **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. +- A re-run carries nothing more: the served view already has the form withdrawn. + +No door, no accepted request shape and no error code changes. diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 7abd5b5e3a9..72241687b2b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -41,6 +41,7 @@ import { import { applyOrganizationOwnership, checkpointFromDetails, + protocolCustomizationPairs, MetadataChoiceRefusal, ORGANIZATION_OWNERSHIP_MIGRATION_ID, judgeDeclaredSharingRules, @@ -751,7 +752,7 @@ export default class OrganizationOwnershipCeremony extends Command { // The dialect, once, before the first statement: an unresolved one is // refused here, naming the driver, and never read as SQLite. const dialect = ceremonyDialectOf(stack.driver); - const { normalizeRows } = await import('@objectstack/metadata-protocol'); + const { isWritablePackage, normalizeRows } = await import('@objectstack/metadata-protocol'); // [#23816] A boot that skipped schema sync measured no table absent, so // the ledger's presence is read from the catalog instead. const ledgerAbsent = stack.schemaSync === 'skipped' @@ -774,6 +775,7 @@ export default class OrganizationOwnershipCeremony extends Command { getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>; getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>; getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise; + ensureCustomizationPackage?: (request: { basePackageId: string }) => Promise<{ packageId: string }>; } | undefined; const catalog = createSecurityCatalogReader({ registry: engine.registry as never, @@ -926,6 +928,21 @@ export default class OrganizationOwnershipCeremony extends Command { }; }, }, + // [ADR-0146 D1, D3] A withdrawal onto a read-only package's view goes + // into its `.custom` pair: the save door's own writability predicate, + // and the ensure door, in process. + pairs: protocolCustomizationPairs({ + ensureCustomizationPackage: async (request) => { + if (typeof protocol?.ensureCustomizationPackage !== 'function') { + throw new Error('no metadata protocol is composed on this stack'); + } + return protocol.ensureCustomizationPackage(request); + }, + isWritablePackage: (packageId) => isWritablePackage(engine, packageId), + baseVersion: (packageId) => + (engine.registry as { getPackage?(id: string): { manifest?: { version?: unknown } } | undefined } | undefined) + ?.getPackage?.(packageId)?.manifest?.version, + }), views: { // The env-wide view list, exactly as the anonymous form doors read it. list: async () => { diff --git a/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts new file mode 100644 index 00000000000..5ef2c2ad3bd --- /dev/null +++ b/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate organization-ownership --apply`, the form-withdrawal carry onto + * a view a READ-ONLY package ships (ADR-0146 D1, D2, D3). + * + * The read-only base itself takes no customization: the metadata door refuses + * a save naming it where its `.custom` pair can take the write (the named-base + * close). So the carry ensures the pair through the ensure door and saves the + * withdrawal into it, declaring `overrides: { package: , basedOn }`. + * + * ## The fixture + * + * A project whose config is a booted code package (read-only: its manifest is + * registered at boot) shipping an object and a public form view, over a + * database a served boot of this tree left behind, shaped like v17: + * `sys_metadata` carries `organization_id`, and an organization's row of the + * form view withdraws it (`sharing.enabled: false`). + * + * ## What each case measures + * + * The real command, in process through oclif (plan, then `--apply`): the step + * carries the withdrawal into the pair, the pair is in `sys_packages`, its row + * declares the override at the base's version, and the env-wide view list the + * anonymous doors judge by serves the form withdrawn. A second `--apply` over + * the same database finds the step checkpointed and writes nothing. (A re-run + * of the carry itself, with no checkpoint, is pinned in + * `organization-ownership-apply.test.ts`.) + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; +import { isExitSignal } from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import MigrateOrganizationOwnership from './organization-ownership.js'; + +// [#10126] Pay the first load of what the command reaches through dynamic +// `import()`s at MODULE LOAD, outside the clocked case. +import '@objectstack/runtime'; +import '@objectstack/objectql'; +import '@objectstack/metadata-protocol'; +import { PackageServicePlugin } from '@objectstack/service-package'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; + +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', + 'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'OS_MULTI_ORG_ENABLED', +] as const; + +const BASE = 'com.example.wdpair'; +const PAIR = `${BASE}.custom`; +const VERSION = '1.4.0'; +const VIEW = 'wdp_lead.intake'; +const SLUG = 'wdp-intake'; +const DATA = { provider: 'object', object: 'wdp_lead' }; +const FORM = { + type: 'simple', + data: DATA, + sections: [{ name: 'main', label: 'Main', columns: 1, fields: [{ field: 'name' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, +}; +const CONFIG = { + manifest: { id: BASE, name: 'Withdrawal Pair', version: VERSION, type: 'app', namespace: 'wdp' }, + objects: [{ name: 'wdp_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }], + views: [{ list: { label: 'Leads', type: 'grid', data: DATA, columns: [{ field: 'name' }] }, formViews: { intake: FORM } }], +}; +/** The organization's 17.x row of the form view: the served body, the form withdrawn. */ +const WITHDRAWN = { + name: VIEW, + object: 'wdp_lead', + viewKind: 'form', + label: 'Intake', + config: { ...FORM, sharing: { ...FORM.sharing, enabled: false } }, +}; + +let dir: string; +let projectDir: string; +let templateDb: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** The real command over `dbFile`: the plan run first (its file is the reviewed plan), then `--apply`. */ +async function applyOver(dbFile: string, tag: string): Promise<{ report: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + process.chdir(projectDir); + let exitCode: number | undefined; + try { + const planFile = join(projectDir, `plan-${tag}.json`); + await MigrateOrganizationOwnership.run(['--out', planFile, '--database-url', `file:${dbFile}`, '--json'], { root: CLI_ROOT }); + stdout.mockClear(); + try { + await MigrateOrganizationOwnership.run( + [ + '--apply', '--backup-taken', '--plan-file', planFile, + '--out', join(projectDir, `apply-${tag}.json`), '--database-url', `file:${dbFile}`, '--json', + ], + { root: CLI_ROOT }, + ); + } catch (error) { + if (!isExitSignal(error)) throw error; + exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim()); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + return { report: payload.report, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.chdir(savedCwd); + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +const promotion = (report: any): { status: string; summary: Record; blockers: string[] } => + report.steps.find((s: { step: string }) => s.step === 'metadata-promotion'); + +/** The stored `view` rows of the form, `package/state`, with the declaration each body carries. */ +function storedForm(dbFile: string): Array<{ pkg: string; enabled: unknown; overrides: unknown }> { + const db = new Database(dbFile, { readonly: true }); + try { + return (db.prepare(`SELECT package_id, metadata FROM sys_metadata WHERE type = 'view' AND name = ? ORDER BY package_id`) + .all(VIEW) as Array<{ package_id: string | null; metadata: string }>) + .map((row) => { + const body = JSON.parse(row.metadata) as { config?: { sharing?: { enabled?: unknown } }; overrides?: unknown }; + return { pkg: row.package_id ?? '-', enabled: body.config?.sharing?.enabled, overrides: body.overrides }; + }); + } finally { + db.close(); + } +} + +/** On a fresh boot of `dbFile`: the base's served form, and the views the anonymous doors serve the slug from. */ +async function served(dbFile: string): Promise<{ base: unknown; open: string[] }> { + process.chdir(projectDir); + const stack = await bootSchemaStack({ + jsonOutput: false, databaseUrl: `file:${dbFile}`, projectRoot: projectDir, composeHostStack: true, + // The package registry `os serve` composes, so the pair installs from `sys_packages` as it does there. + extraPlugins: [new PackageServicePlugin()], + }); + try { + const protocol = stack.kernel.getService('protocol') as any; + const base = (await protocol.getMetaItem({ type: 'view', name: VIEW, packageId: BASE }))?.item; + const listed = await protocol.getMetaItems({ type: 'view' }); + const list = ((Array.isArray(listed) ? listed : listed?.items) ?? []) as Array>; + const open = list + .filter((v) => anonymousFormIntakeCandidates(v).some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(list, v, c))) + .map((v) => `${String(v.name)}|${String(v._packageId ?? '')}`); + return { base: (base as { config?: { sharing?: unknown } } | undefined)?.config?.sharing, open }; + } finally { + await stack.shutdown(); + process.chdir(savedCwd); + } +} + +beforeAll(async () => { + for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key]; + for (const key of OVERRIDING_ENV) delete process.env[key]; + process.env.NODE_ENV = 'production'; + process.env.OS_SECRET_KEY = randomBytes(32).toString('hex'); + + dir = mkdtempSync(join(tmpdir(), 'os-ceremony-withdrawal-pair-')); + projectDir = join(dir, 'project'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'objectstack.config.mjs'), `export default ${JSON.stringify(CONFIG, null, 2)};\n`); + + // A served boot of this tree provisions the tables; then the v17 shape. + templateDb = join(dir, 'template.db'); + process.chdir(projectDir); + try { + const seed = await bootSchemaStack({ + jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: projectDir, composeHostStack: true, + extraPlugins: [new PackageServicePlugin()], + }); + await seed.shutdown(); + } finally { + process.chdir(savedCwd); + } + const db = new Database(templateDb); + try { + db.exec(`ALTER TABLE "sys_metadata" ADD COLUMN "organization_id" varchar(255)`); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO sys_metadata (id, organization_id, type, name, package_id, state, version, created_at, updated_at, metadata) ` + + `VALUES ('md_org', 'org_x', 'view', ?, NULL, 'active', 1, ?, ?, ?)`, + ).run(VIEW, now, now, JSON.stringify(WITHDRAWN)); + } finally { + db.close(); + } +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dir, { recursive: true, force: true }); +}); + +describe('organization-ownership --apply: a withdrawal onto a read-only package\'s view goes into its .custom pair', () => { + it('⭐ carried into the pair, declared as the override of the base; the served form is withdrawn; a re-run carries nothing', async () => { + const dbFile = join(dir, 'case.db'); + copyFileSync(templateDb, dbFile); + expect((await served(dbFile)).open, 'PRECONDITION: the shipped form is served open').toEqual([`${VIEW}|${BASE}`]); + + const { report } = await applyOver(dbFile, 'first'); + const step = promotion(report); + expect(step.blockers.filter((b) => b.includes(VIEW))).toEqual([]); + expect(step.summary.withdrawalsCarried).toBe(1); + expect(storedForm(dbFile).filter((r) => r.pkg === PAIR)).toEqual([ + { pkg: PAIR, enabled: false, overrides: { package: BASE, basedOn: VERSION } }, + ]); + // ⛔ Nothing bound to the read-only base itself. + expect(storedForm(dbFile).filter((r) => r.pkg === BASE)).toEqual([]); + + const after = await served(dbFile); + expect(after.base).toMatchObject({ enabled: false }); + expect(after.open).toEqual([]); + + // A re-run finds the step checkpointed and writes nothing more. + const rows = storedForm(dbFile); + const again = await applyOver(dbFile, 'again'); + expect(promotion(again.report).status).toBe('checkpointed'); + expect(storedForm(dbFile)).toEqual(rows); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 120d3e19bde..04a8a087c63 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -39,6 +39,7 @@ import { type CeremonyCheckpoint, type CeremonyLedgerPort, type ColumnDropPort, + type CustomizationPairPort, type DefaultOrganizationPort, type MetadataDoorPort, type MetadataLockPort, @@ -465,6 +466,8 @@ const apply = ( securityPlugin?: SchemaMigrationComposition['securityPlugin']; defaultOrganization?: DefaultOrganizationPort; declaredSharingRules?: readonly string[]; + pairs?: CustomizationPairPort; + onProgress?: (message: string) => void; } = {}, ): Promise => { c.boot(); @@ -487,6 +490,8 @@ const apply = ( ...(opts.metadataChoices ? { metadataChoices: opts.metadataChoices } : {}), ...(opts.securityPlugin ? { securityPlugin: opts.securityPlugin } : {}), ...(opts.defaultOrganization ? { defaultOrganization: opts.defaultOrganization } : {}), + ...(opts.pairs ? { pairs: opts.pairs } : {}), + ...(opts.onProgress ? { onProgress: opts.onProgress } : {}), ...(opts.declaredSharingRules ? { declaredSharingRules: { rules: async () => opts.declaredSharingRules!.map((name) => ({ name, refused: null })) } } : {}), @@ -1271,6 +1276,118 @@ describe('ADR-0131 D10 --apply — organization customizations and form withdraw expect(step(report).status).toBe('blocked'); expect(step(report).blockers.join('\n')).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE.*sharing\.enabled false/); }); + + describe('[ADR-0146 D1, D2, D3] a read-only package\'s view takes the withdrawal in its .custom pair', () => { + /** The pair doors: `readOnly` names the read-only packages; the ensure door answers `.custom` at version 2.1.0. */ + const pairsOf = (readOnly: readonly string[], refuseEnsure?: string): CustomizationPairPort & { ensured: string[] } => { + const ensured: string[] = []; + return { + ensured, + readOnly: async (packageId) => readOnly.includes(packageId), + ensure: async (base) => { + if (refuseEnsure) throw new Error(refuseEnsure); + ensured.push(base); + return { packageId: `${base}.custom`, basedOn: '2.1.0' }; + }, + }; + }; + /** + * The env-wide list as the reads serve it (ADR-0146 D8 item 2): a pair's + * save declaring the override of its base is served at the base's slot, + * over the base's own document. + */ + const servedOverPairs = (c: ReturnType): ServedViewsPort => { + const plain = servedViews(c); + return { + async list() { + const slots = new Map((await plain.list()).map((v) => [`${String(v.name)}|${String(v._packageId ?? '')}`, v])); + for (const [key, v] of [...slots]) { + const declared = (v.overrides as { package?: unknown } | undefined)?.package; + if (typeof declared !== 'string' || v._packageId !== `${declared}.custom`) continue; + slots.delete(key); + slots.set(`${String(v.name)}|${declared}`, { ...v, _packageId: declared }); + } + return [...slots.values()]; + }, + }; + }; + const saved = (c: ReturnType) => + c.viewSaves.map((v) => [v.name, v.packageId, (v.body.config as any).sharing.enabled, v.body.overrides]); + + it('⭐ carried into the pair, declared at the base\'s version; the doors serve it withdrawn; a re-run carries nothing', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy'))); + const c = catalog(); + const views = servedOverPairs(c); + const pairs = pairsOf(['pkg_a']); + const progress: string[] = []; + expect(await doorsServe(views, 'lead-intake')).toEqual(['crm_lead.intake|pkg_a']); + + const report = await apply(c, ledger(), { views, pairs, onProgress: (m) => progress.push(m) }); + + expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } }); + expect(pairs.ensured).toEqual(['pkg_a']); + // ⛔ Nothing saved naming the read-only base itself. + expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a.custom', false, { package: 'pkg_a', basedOn: '2.1.0' }]]); + expect(progress.filter((m) => m.includes('pkg_a.custom'))).toHaveLength(1); + expect(await doorsServe(views, 'lead-intake')).toEqual([]); + + const again = await apply(c, ledger(), { views, pairs }); + expect(step(again).summary.withdrawalsCarried).toBe(0); + expect(c.viewSaves).toHaveLength(1); + }); + + it('a writable package\'s view is saved in place, as before; no pair is ensured', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy'))); + const c = catalog(); + const pairs = pairsOf([]); + const report = await apply(c, ledger(), { views: servedViews(c), pairs }); + expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } }); + expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a', false, undefined]]); + expect(pairs.ensured).toEqual([]); + }); + + it('a pair that cannot be ensured keeps the blocker, the pair\'s refusal named beside the door\'s; nothing is written', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false))); + const c = catalog(); + const refusing: ReturnType = { + ...c, + async save(type, name, body, target) { + if (type === 'view') throw new Error('NOT_OVERRIDABLE: the read-only base takes no customization'); + return c.save(type, name, body, target); + }, + }; + const report = await apply(refusing, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a'], 'NAMESPACE_CONFLICT: the pair namespace is held') }); + expect(step(report).status).toBe('blocked'); + const blocker = step(report).blockers.join('\n'); + expect(blocker).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE/); + expect(blocker).toMatch(/customization package of pkg_a could not be ensured: NAMESPACE_CONFLICT/); + expect(c.viewSaves).toEqual([]); + }); + + it('a door that refuses the declared save into the pair keeps the blocker too, naming that refusal', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false))); + const c = catalog(); + const refusing: ReturnType = { + ...c, + async save(type, name, body, target) { + if (type === 'view') throw new Error(`NOT_OVERRIDABLE: refused in ${String(target?.packageId)}`); + return c.save(type, name, body, target); + }, + }; + const report = await apply(refusing, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a']) }); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/refused in pkg_a\. Nor could its customization package take it: the metadata door refused the save into pkg_a\.custom: NOT_OVERRIDABLE: refused in pkg_a\.custom/); + expect(c.viewSaves).toEqual([]); + }); + + it('control: where the pair cannot take it and the door admits the save naming the package, that save carries it, as before', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false))); + const c = catalog(); + const report = await apply(c, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a'], 'the base declares no namespace') }); + expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } }); + expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a', false, undefined]]); + }); + }); }); describe('ADR-0131 D10 --apply — a customization conflict resolved by --metadata-choice (ruling 6163114098: Q1 B, Q6 A)', () => { diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 7c99b779d80..b629ad6b6c2 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -77,7 +77,10 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing'; * organization's withdrawal wins), by writing it onto each active * environment `view` of that name that still serves the form open, * through the metadata door; the same predicate the anonymous doors - * judge by (`anonymousFormIntakeWithdrawnIn`) decides. Then, under + * judge by (`anonymousFormIntakeWithdrawnIn`) decides. A view a + * read-only package ships takes it in that package's `.custom` pair, + * declared as the override of the base (ADR-0146 D1, D2, D3: + * {@link CustomizationPairPort}). Then, under * `single`, each organization-scoped `sys_metadata` row of the five * presentational types whose overlay key no other row holds becomes an * environment row in place, its history with it (its `organization_id` @@ -268,6 +271,49 @@ export interface MetadataDoorPort { ): Promise; } +/** + * [ADR-0146 D1, D2, D3] The customization pair of a read-only package, for the + * one write the ceremony makes onto an item such a package ships: the + * withdrawal carry. The read-only base itself takes no customization, so the + * carry saves into `.custom`, declaring `overrides: { package: , + * basedOn }` ({@link carryFormWithdrawals}). + */ +export interface CustomizationPairPort { + /** Whether the installed package is read-only: the server's own writability verdict, negated (ADR-0070 D2). */ + readOnly(packageId: string): Promise; + /** + * Ensure the base's pair through the ensure door (`POST + * /packages/:id/ensure-customization`, in process). Throws the door's + * refusal. Answers the pair's id and the base's version now: the `basedOn` + * the override declares. + */ + ensure(basePackageId: string): Promise<{ packageId: string; basedOn: string }>; +} + +/** + * The command's {@link CustomizationPairPort}, over the booted stack: the + * writability predicate the save door judges by, and the protocol's ensure + * door. `basedOn` is the installed base's `manifest.version`, the value the + * package-less overlay ceremony records too. + */ +export function protocolCustomizationPairs(deps: { + ensureCustomizationPackage(request: { basePackageId: string }): Promise<{ packageId: string }>; + isWritablePackage(packageId: string): boolean; + baseVersion(packageId: string): unknown; +}): CustomizationPairPort { + return { + readOnly: async (packageId) => !deps.isWritablePackage(packageId), + ensure: async (basePackageId) => { + const { packageId } = await deps.ensureCustomizationPackage({ basePackageId }); + const basedOn = deps.baseVersion(basePackageId); + if (typeof basedOn !== 'string' || basedOn === '') { + throw new Error(`the installed package '${basePackageId}' declares no version, so no basedOn can be declared`); + } + return { packageId, basedOn }; + }, + }; +} + /** The env-wide `view` list the anonymous form doors read (every package's item of a name). */ export interface ServedViewsPort { list(): Promise>>>; @@ -641,6 +687,12 @@ export interface ApplyOptions { notNull?: NotNullPort; /** The served views, for carrying form withdrawals. */ views: ServedViewsPort; + /** + * The customization pair a withdrawal onto a read-only package's view is + * carried into. Absent, the carry saves naming the served package, and the + * door's refusal of that save blocks. + */ + pairs?: CustomizationPairPort; /** The door's lock verdict, for the one write the ceremony makes in place. */ lock: MetadataLockPort; columnDrops: ColumnDropPort; @@ -1546,6 +1598,8 @@ const servedPackageOf = (item: Readonly>): string | null return typeof pkg === 'string' && pkg !== '' && pkg !== METADATA_OBJECT ? pkg : null; }; +const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + /** A served item as a body the door accepts: its runtime annotations (`_`-prefixed keys) dropped. */ const authoredBodyOf = (item: Readonly>): Record => JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))))) as Record; @@ -1564,6 +1618,16 @@ const authoredBodyOf = (item: Readonly>): Record, blockers: string[]): Promise { const t = METADATA_OBJECT; @@ -1599,6 +1663,19 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s for (const candidate of closing) { candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false }; } + let pairRefusal: string | undefined; + if (packageId !== null && run.opts.pairs) { + const outcome = await carryIntoPair(run, run.opts.pairs, name, packageId, body); + if (outcome.carried) { + summary.withdrawalsCarried += closing.length; + run.opts.onProgress?.( + `carried ${closing.length} public-form withdrawal(s) onto the view ${name} through the customization package ` + + `${outcome.pair}, declared as the override of ${packageId} at ${outcome.basedOn}`, + ); + continue; + } + pairRefusal = outcome.refusal; + } try { await run.opts.door.save('view', name, body, { packageId }); summary.withdrawalsCarried += closing.length; @@ -1607,13 +1684,54 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s blockers.push( `An organization withdrew the public form(s) ${closing.map((c) => c.slug).join(', ')} of the view ${name}` + `${packageId ? ` (package ${packageId})` : ''}, and the metadata door refused to carry that withdrawal onto ` + - `the environment: ${error instanceof Error ? error.message : String(error)}. Withdraw the form there ` + - '(sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form (ruling record 6020151485).', + `the environment: ${messageOf(error)}.${pairRefusal ? ` Nor could its customization package take it: ${pairRefusal}.` : ''} ` + + 'Withdraw the form there (sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form ' + + '(ruling record 6020151485).', ); } } } +/** + * [ADR-0146 D1, D2, D3] Carry a withdrawal onto `base`'s view `name` into the + * base's `.custom` pair, when `base` is read-only: the pair ensured through + * the ensure door, then `body` saved into it 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). `carried: false` with no refusal: `base` is writable, and its + * view is saved in place. With one: the pair could not take it, in the + * door's or the ensure door's own words. + */ +async function carryIntoPair( + run: Run, + pairs: CustomizationPairPort, + name: string, + base: string, + body: Record, +): Promise<{ carried: true; pair: string; basedOn: string } | { carried: false; refusal?: string }> { + try { + if (!(await pairs.readOnly(base))) return { carried: false }; + } catch (error) { + return { carried: false, refusal: `whether the package ${base} is read-only could not be read: ${messageOf(error)}` }; + } + let pair: { packageId: string; basedOn: string }; + try { + pair = await pairs.ensure(base); + } catch (error) { + return { carried: false, refusal: `the customization package of ${base} could not be ensured: ${messageOf(error)}` }; + } + const own = body.overrides as { package?: unknown; basedOn?: unknown } | undefined; + const overrides = own && typeof own === 'object' && own.package === base && typeof own.basedOn === 'string' + ? { package: base, basedOn: own.basedOn } + : { package: base, basedOn: pair.basedOn }; + try { + await run.opts.door.save('view', name, { ...body, overrides }, { packageId: pair.packageId }); + } catch (error) { + return { carried: false, refusal: `the metadata door refused the save into ${pair.packageId}: ${messageOf(error)}` }; + } + return { carried: true, pair: pair.packageId, basedOn: overrides.basedOn }; +} + /** One conflict the operator chose a row for. */ interface MetadataResolution { conflict: MetadataConflict;