From d74ab15dde8651a7fb071c049cf8934fa0d9c8ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:14:34 +0000 Subject: [PATCH 1/4] wip(metadata-protocol): the packageless-overlays census reaches rows bound to a read-only base Claude-Session: https://claude.ai/code/session_01ADHkmQZSg4zWtKMdRnKmKS Co-authored-by: Claude --- .../commands/migrate/packageless-overlays.ts | 24 ++- .../protocol-packageless-overlay-migration.ts | 77 ++++++++-- ...ocol-packageless-overlay-migration.test.ts | 139 +++++++++++++++++- 3 files changed, 222 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/migrate/packageless-overlays.ts b/packages/cli/src/commands/migrate/packageless-overlays.ts index 825d7c1f1b4..b4c3ccbc527 100644 --- a/packages/cli/src/commands/migrate/packageless-overlays.ts +++ b/packages/cli/src/commands/migrate/packageless-overlays.ts @@ -65,7 +65,11 @@ function reasonOf(row: CensusRow): string { return `'${row.base}' has no paired .custom package id, so there is nowhere to move it. Left as it is.`; case 'foreign_declaration': return `its body already carries an \`overrides\` key that does not name '${row.base}'. Left as it is: ` - + 'remove the key with a save naming no package, then re-run: the move declares the right override.'; + + (row.boundToBase + // [#29392] A save naming the base is closed, so the key cannot be removed in place. + ? `delete it with a delete naming '${row.base}', then save its body into '${row.pair}' declaring ` + + `overrides: { package: '${row.base}', basedOn }.` + : 'remove the key with a save naming no package, then re-run: the move declares the right override.'); case 'shadowed': return (row.shadowDeclares ? `'${row.pair}' already overrides this item (a customization made in Studio), and that row is the one ` @@ -94,6 +98,7 @@ function jsonRow(row: CensusRow, outcome?: Outcome): Record { ...(row.pair !== undefined ? { pair: row.pair } : {}), ...(row.shadowDeclares !== undefined ? { shadowDeclares: row.shadowDeclares } : {}), ...(row.addedMember !== undefined ? { addedMember: row.addedMember } : {}), + ...(row.boundToBase ? { boundToBase: true } : {}), outcome: outcome?.outcome ?? (row.disposition === 'movable' ? 'listed' : 'left'), ...(outcome && outcome.outcome !== 'failed' ? { packageId: outcome.packageId, ...(outcome.basedOn !== undefined ? { basedOn: outcome.basedOn } : {}) } @@ -106,7 +111,9 @@ function jsonRow(row: CensusRow, outcome?: Outcome): Record { * `os migrate packageless-overlays` — list, and with `--apply` move, every * package-less overlay row of a name exactly one installed code package ships * into that package's paired `.custom`, as a declared override - * (ADR-0146 D11, the migration half of N-16). + * (ADR-0146 D11, the migration half of N-16) — and every overlay row bound to + * a read-only code package that ships its name, which a save naming that + * package stored before such a save was closed (listed `boundToBase`). * * ## Why a ceremony * @@ -125,7 +132,7 @@ function jsonRow(row: CensusRow, outcome?: Outcome): Record { * header holds the rules): the pair is ensured through its one creation door, * each body is saved into the pair through the save door with the declaration * (`basedOn` = the installed base's version now), read back as the served - * override, and only then is the package-less row deleted. A name no package + * override, and only then is the moved row deleted. A name no package * ships is environment metadata and is not touched; a name several packages * ship, and a row the pair already overrides (a Studio customization made * first), are listed and left alone. A pair row declaring an override with no @@ -158,8 +165,8 @@ function jsonRow(row: CensusRow, outcome?: Outcome): Record { export default class MigratePackagelessOverlays extends Command { static override description = 'List, and with --apply move, the package-less view, dashboard, report, translation and email-template rows ' - + 'over a name one installed package ships into that package\'s paired .custom package, as declared overrides ' - + '(ADR-0146 D11). Run once after upgrading.'; + + 'over a name one installed package ships, and those bound to a read-only package that ships the name, into ' + + 'that package\'s paired .custom package, as declared overrides (ADR-0146 D11). Run once after upgrading.'; static override examples = [ '$ os migrate packageless-overlays', @@ -242,7 +249,7 @@ export default class MigratePackagelessOverlays extends Command { } printWarning( 'Apply mode moves the listed sys_metadata rows into each base\'s paired .custom package (created when ' - + 'absent) and deletes the package-less rows. Re-run with --yes to confirm, or run without --apply to preview.', + + 'absent) and deletes the rows moved. Re-run with --yes to confirm, or run without --apply to preview.', ); this.exit(1); return; @@ -381,7 +388,8 @@ export default class MigratePackagelessOverlays extends Command { console.log(chalk.bold(`${movable.length} row(s) to move:`)); for (const row of movable) { // [#28904] A member the base does not ship moves in as the pair's own, declaring nothing. - console.log(` • ${itemOf(row)} → ${row.pair} ${row.addedMember !== undefined + // [#29392] A row bound to the base (a save naming it, before that write was closed) moves the same way. + console.log(` • ${itemOf(row)}${row.boundToBase ? ` bound to ${row.base}` : ''} → ${row.pair} ${row.addedMember !== undefined ? `(its own member; ${row.base} ships none at ${row.addedMember})` : `(overrides ${row.base})`}`); } @@ -413,7 +421,7 @@ export default class MigratePackagelessOverlays extends Command { + `${line.declarationsCompleted} basedOn recorded, ${line.shadowed} shadowed, ${line.failed} failed`); } if (failed > 0) { - printError(`${failed} row(s) could not be moved; each package-less row that failed is kept as it was.`); + printError(`${failed} row(s) could not be moved; each row that failed is kept as it was.`); } else { printSuccess(`Done ${chalk.dim(`(${timer.display()})`)}. Restart the server so it serves the moved rows.`); } diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index e5b227dec70..c95c95c7e6c 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -46,6 +46,32 @@ * are never in the population (ADR-0146 D6; their own ceremony is * `os migrate security-catalog-overlays`). * + * ## …and the rows bound to a read-only base (#29392) + * + * Before #28905 closed it, a save naming the read-only code package itself + * (`?package=`) stored the customization as a row bound to the base, + * declaring nothing, served at the base's address in place of its document. + * Such a row is in the population too: a row of one of the five overlay types + * whose `package_id` is a READ-ONLY code package that ships the name — the + * reading the #28905 close names "packaged" (`readOnlyShippingPackages` over + * `shippedArtifactsOf`, its `overlayBaseShippers`), the one this census reads + * for a package-less row too. The binding names the base, so a name several + * packages ship is never `multi_shipped` here: the row is classified by the + * same {@link classifyPackagelessRow} with its binding as the one shipper, + * and carries `boundToBase`. It moves as a package-less row does, with the + * same outcomes (`moved`, or `moved` + `addedMember`), the delete of step 4 + * addressing it by its own binding. A row bound to a writable package (the + * pair included) is that package's own item, edited in place (ADR-0070), and + * a row bound to a read-only package that does not ship the name is not a + * customization of it: neither is listed. + * + * Two rows of one item from different bindings — a package-less one and one + * bound to the base — would both land on the pair's one row. The move takes + * the package-less rows first; a row of an item this run has already moved a + * row of another binding into the pair is kept, its outcome `failed`, and the + * next run lists it `shadowed` (the pair holds the item now), with that + * disposition's two ways out. Nothing is overwritten. + * * ## The move, one row at a time * * 1. The pair is ensured through the ensure door, @@ -162,6 +188,13 @@ export interface PackagelessOverlayRow { * override (module header, "a bundle member the base does not ship"). */ readonly addedMember?: string; + /** + * [#29392] The row is bound to `base`, a read-only code package that ships + * the name — stored by a save naming the base before the #28905 close — + * rather than to no package (module header, "the rows bound to a read-only + * base"). Absent for a package-less row. + */ + readonly boundToBase?: true; } /** One pair row whose declaration lacks `basedOn` (module header, last section). */ @@ -371,7 +404,6 @@ export async function listPackagelessOverlays( const listed: PackagelessOverlayRow[] = []; let environmentRows = 0; for (const row of rows) { - if (!isUnbound(row)) continue; const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; const name = String(row.name); const state = stateOf(row)!; @@ -380,6 +412,10 @@ export async function listPackagelessOverlays( // item is edited in place (ADR-0070) and has no pair (ADR-0146 D3), and // the pair itself is writable — the write gate's own narrowing. const shippers = readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id)); + // [#29392] A bound row is in the population only when its binding is + // one of those read-only shippers: a row bound to the base itself. + const binding = isUnbound(row) ? undefined : String(row.package_id); + if (binding !== undefined && !shippers.includes(binding)) continue; if (shippers.length === 0) { environmentRows += 1; continue; @@ -394,11 +430,18 @@ export async function listPackagelessOverlays( shippers, }; const body = bodyOf(row); - const classified = classifyPackagelessRow(shippers, body, (pair) => shadowOf(pair, type, name, member, state)); + // A row bound to the base names its base: the binding is its one shipper. + const classified = classifyPackagelessRow(binding !== undefined ? [binding] : shippers, body, + (pair) => shadowOf(pair, type, name, member, state)); const addedMember = classified.disposition === 'movable' && classified.base !== undefined ? addedMemberOf(this, type, name, classified.base, body) : undefined; - listed.push({ ...common, ...classified, ...(addedMember !== undefined ? { addedMember } : {}) }); + listed.push({ + ...common, + ...classified, + ...(addedMember !== undefined ? { addedMember } : {}), + ...(binding !== undefined ? { boundToBase: true as const } : {}), + }); } // Every type a declaration applies to, not only the five the move acts on. @@ -500,13 +543,25 @@ export async function migratePackagelessOverlays( for (const row of await environmentRowsOfTypes.call(this, OVERRIDABLE_TYPE_SPELLINGS)) rowsById.set(String(row.id), row); } - for (const row of census.rows) { - if (row.disposition !== 'movable' || row.base === undefined || row.pair === undefined) continue; + // [#29392] The package-less rows first, then the rows bound to a base; an + // item this run moved a row of one binding into keeps a row of the other + // (module header, "the rows bound to a read-only base"). + const movedFrom = new Map(); + const toMove = census.rows.filter((row) => row.disposition === 'movable' && row.base !== undefined && row.pair !== undefined); + for (const row of [...toMove.filter((r) => !r.boundToBase), ...toMove.filter((r) => r.boundToBase)]) { + if (row.base === undefined || row.pair === undefined) continue; const line = lineOf(row.base, row.pair); const fail = (error: string) => { line.failed += 1; outcomes.push({ id: row.id, outcome: 'failed', error }); }; + const itemKey = `${row.pair}\u0000${row.type}\u0000${row.name}\u0000${row.member ?? ''}`; + const bound = row.boundToBase === true; + if (movedFrom.has(itemKey) && movedFrom.get(itemKey) !== bound) { + fail(`'${row.pair}' now holds this item: this run moved its ${bound ? 'package-less row' : `row bound to '${row.base}'`} ` + + 'there, and the row is kept beside it, never merged. Re-run the preview for the ways out.'); + continue; + } const ensuredPair = await ensure(row.base, row.pair); if (!ensuredPair.ok) { fail(ensuredPair.error); continue; } // [#28904] A member the base does not ship is the pair's own: no @@ -533,12 +588,14 @@ export async function migratePackagelessOverlays( // or, for an added member, that member's own row declaring nothing. const written = await storedPairRow(this, row, row.pair); if (addedMember === undefined ? !isServedOverrideRowOf(written, row.base) : !isOwnMemberRow(written)) { + const kept = bound ? `the row bound to '${row.base}' is kept` : 'the unbound row is kept'; fail(addedMember === undefined - ? `The row saved into '${row.pair}' does not read back as the declared override of '${row.base}'; the unbound row is kept.` - : `The row saved into '${row.pair}' does not read back as its own member '${addedMember}', declaring no override; the unbound row is kept.`); + ? `The row saved into '${row.pair}' does not read back as the declared override of '${row.base}'; ${kept}.` + : `The row saved into '${row.pair}' does not read back as its own member '${addedMember}', declaring no override; ${kept}.`); continue; } - // Step 4: only now, the unbound row, by its own address. + // Step 4: only now, the unbound row (or the row bound to the + // base), by its own address. await repository.delete( { type: row.storedType, name: row.name, org: 'env' } as never, { @@ -550,10 +607,12 @@ export async function migratePackagelessOverlays( : `Moved into '${row.pair}' as its own member '${addedMember}', which '${row.base}' does not ship (ADR-0146 D2, D11)`, intent: 'override-artifact', state: row.state, - packageId: null, + // [#29392] A row bound to the base is deleted by that binding. + packageId: bound ? row.base : null, ...(row.member !== undefined ? { member: row.member } : {}), } as never, ); + movedFrom.set(itemKey, bound); line.moved += 1; outcomes.push(addedMember === undefined ? { id: row.id, outcome: 'moved', packageId: row.pair, basedOn: basedOn! } diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index a1773ec0f5b..3b6c48a08be 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -25,7 +25,11 @@ * unmigrated row is served, and its package-less write is refused; a * rollback of the pair's declared row is the declared override (a page); * - ADOPT-ORPHANS (Wanted 5): adopting into a writable package skips the - * shipped-name row and rebinds an environment one. + * shipped-name row and rebinds an environment one; + * - [#29392] A ROW BOUND TO THE BASE (a save naming it, before #28905's + * close): listed and moved the same way, never merged over another row of + * its item; rows bound to a writable package or to a package that does not + * ship the name are not listed. * * Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s * `dist/`, so a change there is read here after that package is rebuilt. @@ -635,6 +639,139 @@ describe('[#28576] the close reaches only what the ceremony moves: every row it }); }); +/** + * [#29392, ADR-0146 D1, D11] A row bound to the read-only base itself, as a + * save naming the base stored it before #28905 closed that write (laid down + * through the engine, as `storeRow` lays down every older release's row), is + * in the census beside the package-less rows: listed `movable` with + * `boundToBase`, its binding as its base, and moved into the pair by + * `--apply` with the declaration — the same outcome a package-less row's move + * answers. Before, the census read package-less rows only, so the dry run + * listed nothing and the row's one exit was a delete, which drops it. + */ +describe('[#29392] the census and the move reach a row bound to the read-only base, stored before the #28905 close', () => { + const listedRows = (c: any) => (c.rows as any[]).map((r) => `${r.name}@${r.state} ${r.disposition} ${r.base} → ${r.pair}${r.boundToBase ? ' bound' : ''}`); + + it('the dry run lists it with its target pair; --apply moves it with its `overrides` declaration; the base\'s address serves the customization over the base\'s document after; a re-run moves 0', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, BASE); + await storeRow(engine, VIEW, 'showcase_task', EDITED_COLUMNS, BASE, 'draft'); + const servedBefore = columnsOf(await read(protocol, BASE)); + expect(servedBefore).toEqual(UNBOUND_COLUMNS); + + const dry = await census(protocol); + expect(listedRows(dry)).toEqual([ + `${VIEW}@active movable ${BASE} → ${PAIR} bound`, + `${VIEW}@draft movable ${BASE} → ${PAIR} bound`, + ]); + expect(dry.environmentRows).toBe(0); + // The dry run writes nothing. + expect(rows(VIEW).slice().sort()).toEqual([[BASE, 'active'], [BASE, 'draft']]); + expect((engine.registry as any).getPackage(PAIR)).toBeUndefined(); + + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => [o.outcome, o.packageId, o.basedOn])).toEqual([ + ['moved', PAIR, '1.2.0'], ['moved', PAIR, '1.2.0'], + ]); + expect(result.bases).toEqual([{ base: BASE, pair: PAIR, pairCreated: true, moved: 2, declarationsCompleted: 0, failed: 0, shadowed: 0 }]); + expect(rows(VIEW).slice().sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); + for (const state of ['active', 'draft']) { + const moved = rowOf(VIEW, PAIR, state)!; + expect(isServedOverrideRowOf(moved as any, BASE), state).toBe(true); + expect(JSON.parse(String(moved.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); + } + expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR, 'draft')!.metadata)))).toEqual(EDITED_COLUMNS); + // The base's document is the code layer; the customization, now the + // pair's declared override, is served over it at every read. + const view = await layered(protocol, BASE); + expect(columnsOf(view.code)).toEqual(BASE_COLUMNS); + expect(columnsOf(view.effective)).toEqual(UNBOUND_COLUMNS); + expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); + + const again = await migrate(protocol); + expect(again.rows).toEqual([]); + expect(again.outcomes).toEqual([]); + }); + + it('a name two read-only packages ship, bound to one of them: the binding names its base, so it is movable into that base\'s pair, never `multi_shipped`', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await storeRow(engine, SHARED_VIEW, 'shared_task', UNBOUND_COLUMNS, BASE); + const dry = await census(protocol); + expect(listedRows(dry)).toEqual([`${SHARED_VIEW}@active movable ${BASE} → ${PAIR} bound`]); + expect(dry.rows[0].shippers).toEqual([OTHER, BASE]); + expect((await migrate(protocol)).outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + expect(rows(SHARED_VIEW)).toEqual([[PAIR, 'active']]); + expect(JSON.parse(String(rowOf(SHARED_VIEW, PAIR)!.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); + }); + + it('a body declaring another package: listed `foreign_declaration`, bound, and left as it is', async () => { + const { engine, protocol, rows } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, BASE, 'active', { overrides: { package: OTHER } }); + expect(listedRows(await census(protocol))).toEqual([`${VIEW}@active foreign_declaration ${BASE} → ${PAIR} bound`]); + expect((await migrate(protocol)).outcomes).toEqual([]); + expect(rows(VIEW)).toEqual([[BASE, 'active']]); + }); + + it('beside the pair\'s declared row: listed `shadowed`, and left as it is', async () => { + const { engine, protocol, rows } = await boot(); + await (protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + await storeRow(engine, VIEW, 'showcase_task', EDITED_COLUMNS, PAIR, 'active', { overrides: { package: BASE, basedOn: '1.2.0' } }); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, BASE); + const dry = await census(protocol); + expect(listedRows(dry)).toEqual([`${VIEW}@active shadowed ${BASE} → ${PAIR} bound`]); + expect(dry.rows[0].shadowDeclares).toBe(true); + const result = await migrate(protocol); + expect(result.outcomes).toEqual([]); + expect(result.bases).toEqual([{ base: BASE, pair: PAIR, pairCreated: false, moved: 0, declarationsCompleted: 0, failed: 0, shadowed: 1 }]); + expect(rows(VIEW).slice().sort()).toEqual([[BASE, 'active'], [PAIR, 'active']]); + }); + + it('a package-less row and a row bound to the base of one item: the package-less row moves, the bound row is kept (never merged over it), and the next run lists it `shadowed`', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); + await storeRow(engine, VIEW, 'showcase_task', EDITED_COLUMNS, BASE); + const dry = await census(protocol); + expect(listedRows(dry).slice().sort()).toEqual([ + `${VIEW}@active movable ${BASE} → ${PAIR}`, + `${VIEW}@active movable ${BASE} → ${PAIR} bound`, + ]); + const bound = dry.rows.find((r: any) => r.boundToBase); + + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => [o.id === bound.id ? 'bound' : 'package-less', o.outcome])).toEqual([ + ['package-less', 'moved'], ['bound', 'failed'], + ]); + expect(result.outcomes[1].error).toContain(`'${PAIR}' now holds this item`); + expect(result.bases).toEqual([{ base: BASE, pair: PAIR, pairCreated: true, moved: 1, declarationsCompleted: 0, failed: 1, shadowed: 0 }]); + // Nothing overwritten: the pair holds the package-less row's content, the bound row is untouched. + expect(rows(VIEW).slice().sort()).toEqual([[BASE, 'active'], [PAIR, 'active']]); + expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR)!.metadata)))).toEqual(UNBOUND_COLUMNS); + expect(columnsOf(JSON.parse(String(rowOf(VIEW, BASE)!.metadata)))).toEqual(EDITED_COLUMNS); + expect(listedRows(await census(protocol))).toEqual([`${VIEW}@active shadowed ${BASE} → ${PAIR} bound`]); + }); + + it('CONTROL: a package-less row still moves as before, beside a row bound to a writable package (not listed) and a row bound to a read-only package that does not ship the name (not listed)', async () => { + const { engine, protocol, rows } = await boot(); + await (protocol as any).installPackage({ manifest: { id: WORKSPACE, name: 'workspace', version: '0.1.0', type: 'app' } }); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); + await storeRow(engine, ENV_VIEW, 'showcase_task', UNBOUND_COLUMNS, WORKSPACE); + await storeRow(engine, SHARED_VIEW, 'shared_task', UNBOUND_COLUMNS, WORKSPACE); + // OTHER is read-only and does not ship `env_only.default`. + await storeRow(engine, ENV_VIEW, 'showcase_task', EDITED_COLUMNS, OTHER); + + const dry = await census(protocol); + expect(listedRows(dry)).toEqual([`${VIEW}@active movable ${BASE} → ${PAIR}`]); + expect(dry.rows[0]).not.toHaveProperty('boundToBase'); + expect(dry.environmentRows).toBe(0); + + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => [o.outcome, o.basedOn])).toEqual([['moved', '1.2.0']]); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(rows(ENV_VIEW).slice().sort()).toEqual([[OTHER, 'active'], [WORKSPACE, 'active']]); + expect(rows(SHARED_VIEW)).toEqual([[WORKSPACE, 'active']]); + }); +}); + describe('[#28413] adopt-orphans leaves a shipped name to the ceremony', () => { it('adopting into a writable package skips the shipped-name row and rebinds an environment one', async () => { const { engine, protocol, rows } = await boot(); From 387ff2b2822f91451bc62485a529d3c29e9c0beb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:16:27 +0000 Subject: [PATCH 2/4] test(objectql): a base-bound unshipped locale moves as the pair's own member Claude-Session: https://claude.ai/code/session_01ADHkmQZSg4zWtKMdRnKmKS Co-authored-by: Claude --- ...nment-overlay-email-locale-restart.test.ts | 26 +++++++++++++++++++ ...ocol-packageless-overlay-migration.test.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index b197a4d922d..959fae50797 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -1028,6 +1028,32 @@ describe('[#28904] the ceremony moves a row of a member the package does not shi expect(rowsOf(stores, DIGEST).map((r) => [r.packageId, r.member, r.state])).toEqual([[PAIR, 'zh-CN', 'active']]); }); + it('[#29392] an unshipped locale in a row bound to the package itself (a save naming it, before that write was closed): listed bound, and moved into the pair as its own member', async () => { + const stores = new Map>>(); + const first = await boot(stores); + await (first.raw as any).getOverlayRepo().put( + { type: T, name: DIGEST, org: 'env' }, + { ...template('zh-CN', `ADMIN ${DIGEST} zh-CN`), name: DIGEST, ...ENVELOPE }, + { parentVersion: null, actor: null, source: 'older-release', intent: 'override-artifact', packageId: PKG }, + ); + const { raw } = await boot(stores); + expect(rowsOf(stores, DIGEST).map((r) => [r.packageId, r.member])).toEqual([[PKG, 'zh-CN']]); + + const listed = await census(raw); + expect(listed.rows.map((r: any) => [r.name, r.member, r.disposition, r.addedMember, r.base, r.boundToBase])) + .toEqual([[DIGEST, 'zh-CN', 'movable', 'zh-CN', PKG, true]]); + const result = await migrate(raw); + expect(result.outcomes).toEqual([{ id: listed.rows[0].id, outcome: 'moved', packageId: PAIR, addedMember: 'zh-CN' }]); + const stored = rowsOf(stores, DIGEST); + expect(stored.map((r) => [r.packageId, r.member, r.state])).toEqual([[PAIR, 'zh-CN', 'active']]); + expect(stored[0]!.body).not.toHaveProperty('overrides'); + for (const served of [raw, (await boot(stores)).raw]) { + expect(await sentMember(served, DIGEST, 'zh-CN')).toMatchObject({ subject: `ADMIN ${DIGEST} zh-CN`, ...ENVELOPE }); + expect((await sentMember(served, DIGEST, 'en-US'))?.subject).toBe('PKG digest en'); + } + expect((await migrate(raw)).rows).toEqual([]); + }); + it('an unshipped locale whose stored body declares the base: the declaration is dropped, as the pair\'s own member carries none', async () => { const { stores, raw } = await withUnboundZh(DIGEST, { overrides: { package: PKG, basedOn: '0.9.0' } }); const result = await migrate(raw); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 3b6c48a08be..d9e7775860c 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -767,7 +767,7 @@ describe('[#29392] the census and the move reach a row bound to the read-only ba const result = await migrate(protocol); expect(result.outcomes.map((o: any) => [o.outcome, o.basedOn])).toEqual([['moved', '1.2.0']]); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(rows(ENV_VIEW).slice().sort()).toEqual([[OTHER, 'active'], [WORKSPACE, 'active']]); + expect(rows(ENV_VIEW).slice().sort()).toEqual([[WORKSPACE, 'active'], [OTHER, 'active']]); expect(rows(SHARED_VIEW)).toEqual([[WORKSPACE, 'active']]); }); }); From 022b80c8409581e65e4da7582a30725554ecf27d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:17:34 +0000 Subject: [PATCH 3/4] test(cli): packageless-overlays moves a row bound to the read-only package on SQLite Claude-Session: https://claude.ai/code/session_01ADHkmQZSg4zWtKMdRnKmKS Co-authored-by: Claude --- .../packageless-overlays.integration.test.ts | 109 +++++++++++++++--- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts b/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts index 93ae3b5748b..af09e6bbdc5 100644 --- a/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts +++ b/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts @@ -24,6 +24,9 @@ * controls, and a FRESH boot of the same database then serves the moved * content under the base's name on the package-less read, the read naming the * base, the layered read and the base's list; a second `--apply` moves 0. + * + * [#29392] A second template holds a row bound to the package itself (a save + * naming it, before that write was closed): listed and moved the same way. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; @@ -78,7 +81,7 @@ const viewBody = (name: string, columns: readonly string[]) => ({ config: { type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: columns.map((field) => ({ field })) }, }); -interface SeedRow { type: string; name: string; state: string; body: Record } +interface SeedRow { type: string; name: string; state: string; body: Record; packageId?: string } const SEED: SeedRow[] = [ { type: 'view', name: VIEW, state: 'active', body: viewBody(VIEW, STORED_COLUMNS) }, { type: 'view', name: VIEW, state: 'draft', body: viewBody(VIEW, DRAFT_COLUMNS) }, @@ -89,22 +92,52 @@ const SEED: SeedRow[] = [ { type: 'permission', name: 'm28413_rep', state: 'draft', body: { name: 'm28413_rep', label: 'Saved in the environment', objects: {} } }, ]; +/** + * [#29392] A database whose customization a save naming the package itself + * stored before that write was closed: an active row of the package's view + * bound to the package, beside the package-less environment row (control). + */ +const BOUND_SEED: SeedRow[] = [ + { type: 'view', name: VIEW, state: 'active', body: viewBody(VIEW, STORED_COLUMNS), packageId: PKG }, + { type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) }, +]; + let dir: string; let templateDb: string; +let boundTemplateDb: string; const savedCwd = process.cwd(); const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID'] as const; const savedEnv: Record = {}; let caseSeq = 0; -function caseDb(): string { +function caseDb(template = templateDb): string { caseSeq += 1; const file = join(dir, 'cases', `${caseSeq}.db`); mkdirSync(dirname(file), { recursive: true }); - copyFileSync(templateDb, file); + copyFileSync(template, file); return file; } +/** The rows an older release left, written at the driver the way that release stored them. */ +async function insertRows(dbFile: string, rows: readonly SeedRow[]): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + try { + const k = (driver as any).knex; + const now = new Date().toISOString(); + let n = 0; + for (const row of rows) { + n += 1; + await k('sys_metadata').insert({ + id: `seed_${n}`, type: row.type, name: row.name, package_id: row.packageId ?? null, state: row.state, + version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(row.body), + }); + } + } finally { + await driver.disconnect(); + } +} + /** Every `sys_metadata` row, `type/name@binding/state`, read on a connection of our own. */ async function storedRows(dbFile: string): Promise { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); @@ -215,21 +248,10 @@ beforeAll(async () => { extraPlugins: [new PackageServicePlugin()], }); await seed.shutdown(); - const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: templateDb }, useNullAsDefault: true }); - try { - const k = (driver as any).knex; - const now = new Date().toISOString(); - let n = 0; - for (const row of SEED) { - n += 1; - await k('sys_metadata').insert({ - id: `seed_${n}`, type: row.type, name: row.name, package_id: null, state: row.state, - version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(row.body), - }); - } - } finally { - await driver.disconnect(); - } + boundTemplateDb = join(dir, 'bound-template.db'); + copyFileSync(templateDb, boundTemplateDb); + await insertRows(templateDb, SEED); + await insertRows(boundTemplateDb, BOUND_SEED); }, CASE_TIMEOUT_MS); afterAll(() => { @@ -311,3 +333,54 @@ describe('os migrate packageless-overlays', () => { expect(await storedRows(db)).toEqual(before); }, CASE_TIMEOUT_MS); }); + +/** + * [#29392] A row bound to the read-only package itself, stored by a save + * naming it before that write was closed: the census lists it beside the + * package-less rows (`boundToBase`), and `--apply` moves it into the pair with + * the declaration, the same outcome. Before, the census read package-less + * rows only: the preview listed nothing and the row's one exit was a delete. + */ +describe('os migrate packageless-overlays: a row bound to the read-only package', () => { + it('the preview lists it with its target pair and writes nothing', async () => { + const db = caseDb(boundTemplateDb); + const before = await storedRows(db); + const { payload, exitCode } = await runJson(['--database-url', `file:${db}`, '--json']); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + expect(exitCode).toBe(0); + expect(payload.rows.map((r: any) => `${r.type}/${r.name}/${r.state} ${r.disposition} ${r.base} → ${r.pair} ${r.boundToBase} ${r.outcome}`)).toEqual([ + `view/${VIEW}/active movable ${PKG} → ${PAIR} true listed`, + ]); + expect(payload.movable).toBe(1); + expect(payload.environmentRows).toBe(1); + expect(await storedRows(db)).toEqual(before); + expect(await storedPackages(db)).not.toContain(PAIR); + }, CASE_TIMEOUT_MS); + + it('--apply moves it into the pair with its `overrides` declaration, keeps the control, serves the same customization over the package\'s document, and a re-run moves 0', async () => { + const db = caseDb(boundTemplateDb); + const served = await servedColumns(db); + expect(served[1]).toEqual(STORED_COLUMNS); + + const { payload, exitCode } = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + expect(exitCode).toBe(0); + expect(payload.bases).toEqual([{ base: PKG, pair: PAIR, pairCreated: true, moved: 1, declarationsCompleted: 0, failed: 0, shadowed: 0 }]); + expect(payload.rows.map((r: any) => `${r.state} ${r.outcome} ${r.packageId} ${r.basedOn} ${r.boundToBase}`)).toEqual([ + `active moved ${PAIR} 1.4.0 true`, + ]); + expect(await storedRows(db)).toEqual([`view/${ENV_VIEW}@-/active`, `view/${VIEW}@${PAIR}/active`]); + expect((await storedBody(db, { type: 'view', name: VIEW, package_id: PAIR, state: 'active' })).overrides) + .toEqual({ package: PKG, basedOn: '1.4.0' }); + expect(await storedPackages(db)).toContain(PAIR); + + // A fresh boot serves the customization at every read of the package's item. + expect(await servedColumns(db)).toEqual([STORED_COLUMNS, STORED_COLUMNS, STORED_COLUMNS, STORED_COLUMNS]); + + const again = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']); + expect(again.payload.error, JSON.stringify(again.payload).slice(0, 600)).toBeUndefined(); + expect(again.exitCode).toBe(0); + expect(again.payload.moved).toBe(0); + expect(again.payload.rows).toEqual([]); + }, CASE_TIMEOUT_MS * 3); +}); From 7ebc8019a0d80d61c94fc0749f5c4c9a97449bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:20:05 +0000 Subject: [PATCH 4/4] chore(changeset): packageless-overlays moves rows bound to a read-only base Claude-Session: https://claude.ai/code/session_01ADHkmQZSg4zWtKMdRnKmKS Co-authored-by: Claude --- .../29392-packageless-overlays-base-bound.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/29392-packageless-overlays-base-bound.md diff --git a/.changeset/29392-packageless-overlays-base-bound.md b/.changeset/29392-packageless-overlays-base-bound.md new file mode 100644 index 00000000000..c47d1a44ad5 --- /dev/null +++ b/.changeset/29392-packageless-overlays-base-bound.md @@ -0,0 +1,14 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/cli': patch +--- + +fix(metadata-protocol): `os migrate packageless-overlays` also moves a view, dashboard, report, translation or email template row bound to the read-only package itself into its `.custom` pair + +Clause-②: no + +Metadata-protocol: none + +Before the write naming a read-only code package itself (`?package=`) was closed, such a save stored the customization as a row bound to that package, declaring nothing, served at the package's address in place of its own document. The ceremony's census read rows bound to no package only, so it never listed such a row, and its one exit was a delete, which drops the customization. The census now lists, beside the package-less rows, every row of the five overlay types bound to a read-only package that ships the name (`boundToBase` in `--json`, "bound to PACKAGE" in the listing), classified the same way with that package as its base. `--apply` moves it into the pair exactly as it moves a package-less row: the declared override with `basedOn` (or, for an email template locale the package does not ship, the pair's own member), read back, and only then the bound row deleted. The outcomes are the same `moved` ones. + +A row bound to a writable package (the pair included), or to a read-only package that does not ship the name, is not listed. When one item has both a package-less row and a row bound to the package, the package-less row moves and the bound one is kept and reported failed, never merged over it; the next preview lists it `shadowed`, with that disposition's ways out.