From 921d9263b7d2e0826a46a846058bea7a222c4a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:21:00 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): the reconcile census reads every protocol-*.ts module, and the package-less overlay move heals the registry after its delete The stored-row write census read protocol.ts and the codemod's modules only, so a writer the hand series moved into a protocol-*.ts module was neither routed nor counted. Widened to every non-test protocol-*.ts module, it finds one: migratePackagelessOverlays deleted the unbound row through a second SysMetadataRepository and ran no heal. The delete now takes the one repository and is followed by restoreArtifactRegistryView for an active row, as the delete door's is. Claude-Session: https://claude.ai/code/session_01Utp6CaYnLnm2honH3ermsi Co-authored-by: Claude --- ...packageless-overlay-move-heals-registry.md | 11 +++++ .../protocol-packageless-overlay-migration.ts | 15 +++++-- packages/metadata-protocol/src/protocol.ts | 3 ++ ...iew-expansion-reconcile-call-sites.test.ts | 45 ++++++++++++++----- ...ocol-packageless-overlay-migration.test.ts | 34 ++++++++++++++ 5 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 .changeset/30000-packageless-overlay-move-heals-registry.md diff --git a/.changeset/30000-packageless-overlay-move-heals-registry.md b/.changeset/30000-packageless-overlay-move-heals-registry.md new file mode 100644 index 00000000000..3529fb99412 --- /dev/null +++ b/.changeset/30000-packageless-overlay-move-heals-registry.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): `os migrate packageless-overlays --apply` heals the registry after it deletes the unbound row, as the delete door does + +Clause-②: no +Metadata-protocol: none + +- **Now.** After the ceremony saves a row into its base's `.custom` pair and reads it back, it deletes the unbound row through the protocol's one repository and then runs the delete door's registry heal for an active row. The unbound row's registration leaves with the row, and the pair's row is registered again, so the process serves what a restart serves. Before, the delete went through a second repository and no heal followed, so the unbound row stayed registered beside the base's entry until the next restart. The five reads of the item already served the pair's row, so no read answered differently. +- **Unchanged.** A draft row's removal heals nothing, as a draft discard does not. Outcomes, receipts and the rows written are the same. diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 6cf4c7f8b28..8f4617e19f4 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -142,7 +142,6 @@ import { } from './protocol-packaged-base-refusal.js'; import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; -import { SysMetadataRepository } from './sys-metadata-repository.js'; /** The class members this module reads, typed as the class declares them. */ export interface PackagelessOverlayMigrationHost { @@ -155,6 +154,10 @@ export interface PackagelessOverlayMigrationHost { readonly isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; readonly ensureCustomizationPackage: ObjectStackProtocolImplementation['ensureCustomizationPackage']; readonly saveMetaItem: ObjectStackProtocolImplementation['saveMetaItem']; + /** [#30000] THE repository every `sys_metadata` write goes through; step 4's delete takes it. */ + readonly getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo']; + /** [#30000] The delete door's heal, run after step 4 removes an active row. */ + readonly restoreArtifactRegistryView: ObjectStackProtocolImplementation['restoreArtifactRegistryView']; } /** How the ceremony classified one package-less row (module header, "The population"). */ @@ -541,7 +544,7 @@ export async function migratePackagelessOverlays( } return answer; }; - const repository = new SysMetadataRepository({ engine: this.engine as never, orgLabel: 'env' }); + const repo = this.getOverlayRepo(); const rowsById = new Map(); for (const row of await overlayRows.call(this)) rowsById.set(String(row.id), row); if (census.incompleteDeclarations.length > 0) { @@ -601,7 +604,7 @@ export async function migratePackagelessOverlays( } // Step 4: only now, the unbound row (or the row bound to the // base), by its own address. - await repository.delete( + await repo.delete( { type: row.storedType, name: row.name, org: 'env' } as never, { parentVersion: (typeof stored.checksum === 'string' ? stored.checksum : null) as string, @@ -617,6 +620,12 @@ export async function migratePackagelessOverlays( ...(row.member !== undefined ? { member: row.member } : {}), } as never, ); + // [#30000] The registry follows the removed row, as the delete + // door's heal makes it follow: the row's registration and the + // record of what a view container expanded to leave with it, and + // the pair's row saved in step 2 is registered again. A draft never + // hydrated, so its removal heals nothing. + if (row.state === 'active') await this.restoreArtifactRegistryView(row.type, row.name, null, row.member); movedFrom.set(itemKey, bound); line.moved += 1; outcomes.push(addedMember === undefined diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8ea5370d2cf..2451f7138cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3807,6 +3807,9 @@ export class ObjectStackProtocolImplementation implements isWritablePackage: (...a) => this.isWritablePackage(...a), ensureCustomizationPackage: (...a) => this.ensureCustomizationPackage(...a), saveMetaItem: (...a) => this.saveMetaItem(...a), + getOverlayRepo: (...a) => this.getOverlayRepo(...a), + // [#30000] Awaited here too: the census of `sys_metadata` writes reads every call into the heal. + restoreArtifactRegistryView: async (...a) => { await this.restoreArtifactRegistryView(...a); }, }; return migratePackagelessOverlays.call(host, request); } diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts index dcd12a608d4..ebb2a023f06 100644 --- a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -41,6 +41,16 @@ * and a moved body reads the class as `host.` where the class reads `this.` * (`SELF` below). A member whose body moved is judged by that body; its stub in * `protocol.ts` only hands it the host. + * + * [#30000] ...and every other non-test `protocol-*.ts` module: the earlier hand + * series moved writers out of the class too, as module functions that read the + * class as `this` (a `this: Host` parameter), and a census of the codemod's + * modules alone missed them. The package-less overlay migration's delete of the + * unbound row (`migratePackagelessOverlays`) was one: it wrote through a second + * repository it built itself and reached no heal. It now takes the one + * repository and heals as the delete door does. A test or gate that reads a + * file by path reads the module a body moves to (ruling 6597555122 item 3), so + * a writer that lands in any `protocol-*.ts` module is in the population. */ import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -78,21 +88,21 @@ function scan(file: string, header: RegExp): Scanned { return { file, text, lines, members }; } /** - * [#29985] `protocol.ts`, then every module the codemod split moved a body to: - * `protocol-*.ts` beside it (the census's own module pattern, `HOST_SPLIT.module` - * in `scripts/check-durability-degradation-log-level.mjs`), tests excluded, - * whose header names the codemod. The population is the one this census always - * counted, wherever its bodies now sit; the modules of the earlier hand series - * stay outside it as they were. + * [#29985] `protocol.ts`, then every module a body moved to: `protocol-*.ts` + * beside it (the census's own module pattern, `HOST_SPLIT.module` in + * `scripts/check-durability-degradation-log-level.mjs`), tests excluded. + * [#30000] Every such module, the codemod's and the earlier hand series' alike: + * a write is in the population wherever its body sits. */ const CODEMOD_HEADER = '\n * [#29985 codemod] '; const HOST = scan('protocol.ts', MEMBER_HEADER); const MODULES = readdirSync(fileURLToPath(new URL('.', import.meta.url))) .filter((file) => /^protocol-[a-z0-9-]+\.ts$/.test(file)) .sort() - .map((file) => scan(file, FUNCTION_HEADER)) - .filter((scanned) => scanned.text.slice(0, 400).includes(CODEMOD_HEADER)); + .map((file) => scan(file, FUNCTION_HEADER)); const SCANNED: readonly Scanned[] = [HOST, ...MODULES]; +/** [#30000] Whether a module is the codemod's, by its header; every other one is the hand series'. */ +const isCodemodModule = (scanned: Scanned) => scanned.text.slice(0, 400).includes(CODEMOD_HEADER); const lineOf = (scanned: Scanned, offset: number) => scanned.text.slice(0, offset).split('\n').length - 1; const isCode = (scanned: Scanned, offset: number) => !COMMENT_LINE.test(scanned.lines[lineOf(scanned, offset)]); @@ -155,12 +165,14 @@ const WRITES: ReadonlyArray<{ readonly member: string; readonly verb: string; re { member: 'deleteMetaItemUnaudited', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, { member: 'deleteMetaItemUnaudited', verb: 'this.engine.delete(\'sys_metadata\'', route: { sameMember: 'restoreArtifactRegistryView' } }, { member: 'reassignOrphanedMetadata', verb: 'this.engine.update(\'sys_metadata\'', route: { sameMember: 'applyRegistryWriteThrough' } }, + // [#30000] `os migrate packageless-overlays --apply`: the unbound row, once its pair's row reads back. + { member: 'migratePackagelessOverlays', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, ]; /** The verb as `WRITES` spells it: no whitespace after its open paren, [#29985] the class as `this`. */ const verbOf = (text: string) => text.replace(/\(\s*/, '(').replace(/^host\./, 'this.'); -describe('[#23198] every stored-row write in protocol.ts reaches the reconcile', () => { +describe('[#23198] every stored-row write in protocol.ts and its modules reaches the reconcile', () => { const writes = codeMatches(STORED_ROW_WRITE).map((w) => ({ ...w, verb: verbOf(w.text) })); it('the writes in the source are exactly the recorded ones', () => { @@ -221,8 +233,19 @@ describe('[#23198] every stored-row write in protocol.ts reaches the reconcile', describe('[#29985] the census reads the modules the split moved bodies to', () => { it('reads every codemod module, and the stored-row writes it records sit in them or in protocol.ts', () => { // Non-vacuity: a header filter that matched nothing would quietly shrink the census to protocol.ts. - expect(MODULES.length).toBeGreaterThan(0); - expect(codeMatches(STORED_ROW_WRITE).some(({ scanned }) => scanned !== HOST)).toBe(true); + expect(MODULES.filter(isCodemodModule).length).toBeGreaterThan(0); + expect(codeMatches(STORED_ROW_WRITE).some(({ scanned }) => isCodemodModule(scanned))).toBe(true); + }); + + it('[#30000] reads every other non-test protocol-*.ts module too, and a write the hand series moved is in it', () => { + const all = readdirSync(fileURLToPath(new URL('.', import.meta.url))) + .filter((file) => /^protocol-[a-z0-9-]+\.ts$/.test(file)); + expect(MODULES.map((m) => m.file).sort()).toEqual(all.sort()); + expect(MODULES.filter((m) => !isCodemodModule(m)).length).toBeGreaterThan(0); + expect(codeMatches(STORED_ROW_WRITE) + .filter(({ scanned }) => scanned !== HOST && !isCodemodModule(scanned)) + .map(({ scanned, member }) => `${scanned.file}: ${member}`)) + .toEqual(['protocol-packageless-overlay-migration.ts: migratePackagelessOverlays']); }); }); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 457a94a5bdb..c350f1ce6a9 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -30,6 +30,9 @@ * 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. + * - [#30000] THE REGISTRY FOLLOWS THE MOVE: the unbound row's registration + * leaves with the row, so the process holds what a restart holds, with no + * restart (the delete door's heal, run after step 4). * * Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s * `dist/`, so a change there is read here after that package is rebuilt. @@ -786,3 +789,34 @@ describe('[#28413] adopt-orphans leaves a shipped name to the ceremony', () => { expect(rows(ENV_VIEW)).toEqual([[WORKSPACE, 'active']]); }); }); + +describe('[#30000] the registry follows the move at once: it holds what a restart holds', () => { + /** Every registry entry named `VIEW`, by the columns it serves. */ + const registered = (engine: ObjectQL) => ((engine.registry as any).listItems('view') as any[]) + .filter((item) => item?.name === VIEW) + .map((item) => columnsOf(item).join(',')) + .sort(); + + it('the moved unbound row leaves no registration behind, and every read serves the same with no restart', async () => { + const { engine, protocol, stores } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); + expect(await (protocol as any).loadMetaFromDb()).toMatchObject({ errors: 0 }); + expect(registered(engine)).toEqual([BASE_COLUMNS.join(','), UNBOUND_COLUMNS.join(',')].sort()); + + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + + // A restart: a new engine and protocol over the same rows, the pair + // ensured, then the boot's hydration. + const next = await boot(); + next.stores.set('sys_metadata', stores.get('sys_metadata')!); + await (next.protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + expect(await (next.protocol as any).loadMetaFromDb()).toMatchObject({ errors: 0 }); + + // The unbound row's registration left with the row, in the same process. + expect(registered(engine)).toEqual(registered(next.engine)); + expect(registered(engine)).not.toContain(UNBOUND_COLUMNS.join(',')); + expect(await fiveReads(protocol)).toEqual(await fiveReads(next.protocol)); + expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); + }); +}); From 5b9b737f76bda3150690380110f3519c96569b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:21:48 +0000 Subject: [PATCH 2/2] test(metadata-protocol): the census names a recorded write missing from the source instead of crashing on it Claude-Session: https://claude.ai/code/session_01Utp6CaYnLnm2honH3ermsi Co-authored-by: Claude --- .../src/protocol.view-expansion-reconcile-call-sites.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts index ebb2a023f06..c233f1658c3 100644 --- a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -188,7 +188,9 @@ describe('[#23198] every stored-row write in protocol.ts and its modules reaches it('a same-member route is called after its write and before the member\'s next write', () => { for (const row of WRITES) { if (!('sameMember' in row.route)) continue; - const write = writes.find((w) => w.member === row.member && w.verb === row.verb)!; + const found = writes.find((w) => w.member === row.member && w.verb === row.verb); + expect(found, `${row.member}: ${row.verb} is in the source`).toBeDefined(); + const write = found!; const { end } = memberAt(write.scanned, write.offset); const nextWrite = writes.find((w) => w.scanned === write.scanned && w.member === row.member && w.offset > write.offset)?.offset ?? end; const routes = codeMatches(new RegExp(`\\b${SELF}\\.${row.route.sameMember}\\(`))