diff --git a/.changeset/22057-bound-row-not-under-shared-bare-name.md b/.changeset/22057-bound-row-not-under-shared-bare-name.md new file mode 100644 index 00000000000..881697e0a7b --- /dev/null +++ b/.changeset/22057-bound-row-not-under-shared-bare-name.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +A by-name view read naming a package now serves that package's view when another package's stored row has the same name + +Clause-②: no + +- **What was wrong.** Two installed packages can ship a view of one name, and a stored `sys_metadata` view row can be bound to one of them. The registry hydration registered that row under the view's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/view/NAME?package=…`. Meanwhile the list's slot for that package served its own view. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. +- **What it does now.** A view row bound to one package is not registered under a name another package ships. This is the shape the view-container expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own view and envelope. The delete's registry heal also stops re-registering a metadata-service view baseline bound to one package under such a name. +- **Scope: `view` only.** Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and its own package's envelope. A package-less view row, and a view row of a name that only its own package ships or that no package ships, also register as before. The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. +- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`. diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 4a9f8dba8a6..6ac5a2dc1b0 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1354,6 +1354,20 @@ describe('each package\'s copy of a view container expands into its own package\ return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; }, getPackagedObjectOwner: (name: string) => (owner && name === 'task' ? { packageId: owner, ownership: 'own' } : undefined), + // [#22057] The delete's heal, as `SchemaRegistry` holds it: the + // bare entry goes when a package's own entry is under it, and an + // entry that is no packaged artifact goes when nothing is. + removeRuntimeShadow: (type: string, name: string) => { + if (type !== 'view' || !entries.has(name)) return false; + if (!composite(name).some((it) => it._packageId && it._packageId !== 'sys_metadata')) return false; + entries.delete(name); + return true; + }, + removeOverlayEntry: (type: string, name: string) => { + if (type !== 'view' || !entries.has(name) || isCodeArtifactBody(entries.get(name))) return false; + entries.delete(name); + return true; + }, getObject: () => undefined, registerObject: () => {}, getPackage: () => undefined, @@ -1368,12 +1382,18 @@ describe('each package\'s copy of a view container expands into its own package\ ['an unscoped kernel (write-through hydrates the registry)', undefined], ] as const; - function harness(shipped: Array<[string, Record]>, environmentId: string | undefined, owner?: string) { + function harness( + shipped: Array<[string, Record]>, + environmentId: string | undefined, + owner?: string, + metadataService?: unknown, + ) { const { engine, rows } = makeStubEngine(); engine.registry = packagesRegistry(shipped, owner); const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + if (metadataService !== undefined) services.set('metadata', metadataService); const protocol = new ObjectStackProtocolImplementation(engine, () => services, environmentId) as any; - return { protocol, rows }; + return { protocol, rows, registry: engine.registry }; } /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ @@ -1910,5 +1930,193 @@ describe('each package\'s copy of a view container expands into its own package\ }); } }); + + // [#22057] A stored row of a view item name two packages ship, bound to + // one of them. The hydration used to register it under the bare name, + // which the registry answers ahead of either package's own entry, so + // the by-name read naming the OTHER package found no row of its own, + // expanded nothing, and served this row's body under that package's + // envelope, while the list's slot for that package served its own item. + // A cold boot hydrates the stored rows through the same door on either + // kernel. Such a row is not registered: the reads answer it from its + // row, for its own package and for a read naming none, on every kernel. + // ⛔ View only: a row of any other type keeps the bare entry (#4624), + // pinned as the control (e) below. + describe('(j) a stored row bound to one package, of a name another package ships: the read naming each package answers its own item', () => { + const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const; + const KEYED_MEMBER = MEMBERS.find((m) => m.member === 'a keyed member'); + if (!KEYED_MEMBER) throw new Error('no keyed member'); + const [SHARED] = loaderNames(KEYED_MEMBER.body('x', true, SLUG)); + const ROW_TITLE = `Intake (${COPYING} row)`; + const envelopeOf = (v: any) => [titleOf(v), v?._packageId, v?._provenance]; + const shippedBy = (packages: readonly string[]) => packages.map((pkg): [string, Record] => + [pkg, { object: 'task', ...KEYED_MEMBER.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG) }]); + const rowOf = (name: string, title: string) => ({ name, label: title, object: 'task', viewKind: 'form', config: { title } }); + async function saveRow(protocol: any, name: string, packageId: string | undefined, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name, item: rowOf(name, title), ...(packageId ? { packageId } : {}), + })).success).toBe(true); + } + /** A cold boot over the stored rows: a fresh registry of the shipped items, then the boot's hydration. */ + async function bootOver( + rows: Map, + shipped: Array<[string, Record]>, + environmentId: string | undefined, + ): Promise> { + const booted = harness(shipped, environmentId); + for (const [key, row] of rows) booted.rows.set(key, row); + expect((await booted.protocol.loadMetaFromDb()).errors, 'the boot hydrates every stored row').toBe(0); + return booted; + } + const STARTS = [['saved on the running kernel', false], ['after a cold boot over the stored row', true]] as const; + + for (const [kernel, environmentId] of KERNELS) { + for (const order of ORDERS) { + for (const [start, boot] of STARTS) { + const where = `${kernel}; ${order[0]} registered first; ${start}`; + const setUp = async () => { + const saved = harness(shippedBy(order), environmentId); + await saveRow(saved.protocol, SHARED, COPYING, ROW_TITLE); + return boot ? bootOver(saved.rows, shippedBy(order), environmentId) : saved; + }; + + it(`(a) ${where}: the read naming ${OTHER} serves ${OTHER}'s own item under ${OTHER}'s envelope`, async () => { + const { protocol, registry } = await setUp(); + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, packageId: OTHER }); + expect(envelopeOf(read.item), `the read naming ${OTHER}`).toEqual([SHIPPED_TITLE(OTHER), OTHER, 'package']); + expect(read.packageId, 'the envelope the read reports').toBe(OTHER); + expect(await slotTitles(protocol, SHARED, OTHER), `${OTHER}'s slot in the env-wide list`).toEqual([SHIPPED_TITLE(OTHER)]); + // ADR-0048 §3.3: a registry read that carries its package id never mis-resolves. + expect(titleOf(registry.getItem('view', SHARED, OTHER)), `the registry read naming ${OTHER}`).toBe(SHIPPED_TITLE(OTHER)); + }); + + it(`(b) ${where}: the read naming ${COPYING}, the read naming no package and the list scoped to ${COPYING} serve the row`, async () => { + const { protocol } = await setUp(); + for (const packageId of [COPYING, undefined]) { + const named = packageId ?? 'no package'; + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, ...(packageId ? { packageId } : {}) }); + expect(envelopeOf(read.item), `the read naming ${named}`).toEqual([ROW_TITLE, COPYING, 'package']); + expect(read.packageId, `the envelope the read naming ${named} reports`).toBe(COPYING); + } + const scoped: any = await protocol.getMetaItems({ type: 'view', packageId: COPYING }); + expect((scoped.items as any[]).filter((v) => v?.name === SHARED).map(envelopeOf), `the list scoped to ${COPYING}`) + .toEqual([[ROW_TITLE, COPYING, 'package']]); + expect(await slotTitles(protocol, SHARED, COPYING), `${COPYING}'s slot in the env-wide list`).toEqual([ROW_TITLE]); + }); + } + } + } + + // What stays registered under the bare name: a package-less row (the + // overlay of every package's item of the name, ADR-0005, standing in + // for each package, ADR-0048), and a row bound to a package of a name + // only that package ships, or no package ships. The unscoped kernel + // registers on save; a cold boot registers on either kernel. + const STAYS = [ + { row: 'a package-less row of a name one package ships', shipped: [OTHER], bound: undefined, name: SHARED, reads: [OTHER, undefined] }, + { row: 'a package-less row of a name two packages ship', shipped: [OTHER, COPYING], bound: undefined, name: SHARED, reads: [OTHER, COPYING, undefined] }, + { row: `a row bound to ${OTHER}, the one package that ships the name`, shipped: [OTHER], bound: OTHER, name: SHARED, reads: [OTHER, undefined] }, + { row: `a row bound to ${COPYING}, of a name no package ships`, shipped: [OTHER], bound: COPYING, name: 'task.own_form', reads: [COPYING, undefined] }, + ] as const; + for (const [kernel, environmentId] of KERNELS) { + for (const [start, boot] of STARTS) { + for (const stays of STAYS) { + it(`(c) ${kernel}; ${start}; ${stays.row}: served from the row, and registered under the bare name as before`, async () => { + const saved = harness(shippedBy(stays.shipped), environmentId); + await saveRow(saved.protocol, stays.name, stays.bound, ROW_TITLE); + const { protocol, registry } = boot ? await bootOver(saved.rows, shippedBy(stays.shipped), environmentId) : saved; + for (const packageId of stays.reads) { + const read = await protocol.getMetaItem({ type: 'view', name: stays.name, ...(packageId ? { packageId } : {}) }); + expect(titleOf(read.item), `the read naming ${packageId ?? 'no package'}`).toBe(ROW_TITLE); + } + const registers = environmentId === undefined || boot; + expect(titleOf(registry.getItem('view', stays.name)) === ROW_TITLE, 'the bare entry holds the row').toBe(registers); + }); + } + } + } + + // The delete's heal ({@link restoreArtifactRegistryView}): with no + // bare entry to drop, it re-registers the metadata service's answer + // for the name, read naming no package. Where that is one package's + // body under a name another package ships, it is not registered + // either. The service here answers that read alone, so the registry + // is the only thing the read naming the other package can reach. + for (const order of ORDERS) { + it(`(d) an unscoped kernel; ${order[0]} registered first: after the row is deleted, the heal registers no package's body under the name`, async () => { + const service = { + get: async (type: string, name: string, packageId?: string) => (type === 'view' && name === SHARED && packageId === undefined + ? { ...rowOf(SHARED, SHIPPED_TITLE(COPYING)), _packageId: COPYING, _provenance: 'package' } + : undefined), + }; + const { protocol, registry } = harness(shippedBy(order), undefined, undefined, service); + await saveRow(protocol, SHARED, COPYING, ROW_TITLE); + expect((await protocol.deleteMetaItem({ type: 'view', name: SHARED })).success).toBe(true); + + expect(titleOf(registry.getItem('view', SHARED, OTHER)), `the registry read naming ${OTHER}`).toBe(SHIPPED_TITLE(OTHER)); + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, packageId: OTHER }); + expect(envelopeOf(read.item), `the read naming ${OTHER}`).toEqual([SHIPPED_TITLE(OTHER), OTHER, 'package']); + }); + } + + /** + * A registry double for any type, held as `SchemaRegistry` holds it: + * each package's entry under `:`, a hydrated row under + * the bare name, `getItem` bare slot first, and `getArtifactItem`'s + * package-scoped code-artifact lookup. + */ + function typedRegistry() { + const byType = new Map>>(); + const of = (type: string) => byType.get(type) ?? byType.set(type, new Map()).get(type)!; + const composite = (type: string, name: string) => [...of(type)].filter(([key]) => key.endsWith(`:${name}`)).map(([, it]) => it); + return { + registerItem: (type: string, item: Record, _keyField?: string, packageId?: string) => { + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + } + of(type).set(packageId ? `${packageId}:${String(item.name)}` : String(item.name), item); + }, + bare: (type: string, name: string) => of(type).get(name), + listItems: (type: string, packageId?: string) => [...of(type).values()].filter((it) => !packageId || it._packageId === packageId), + getItem: (type: string, name: string, packageId?: string) => of(type).get(name) + ?? (packageId ? of(type).get(`${packageId}:${name}`) : undefined) ?? composite(type, name)[0], + getArtifactItem: (type: string, name: string, packageId?: string) => { + const shippedAs = composite(type, name).filter((it) => isCodeArtifactBody(it)); + return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; + }, + getObject: () => undefined, + registerObject: () => {}, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + // ⛔ View only. A row of any other type, bound to one package, of a + // name two packages ship, holds the bare entry with its own body and + // its own package's envelope, as #4624 rules (ADR-0048; `objectql`'s + // `protocol-boot-hydration-scoped.test.ts`), through either hydration. + for (const order of ORDERS) { + it(`(e) control, an unscoped kernel; ${order[0]} registered first: a page row bound to ${COPYING}, of a name both ship, holds the bare entry with its own body and envelope`, async () => { + for (const hydration of ['the cold boot', 'the list read'] as const) { + const { engine, rows } = makeStubEngine(); + const registry = typedRegistry(); + for (const pkg of order) registry.registerItem('page', { name: 'home', label: `Home (shipped by ${pkg})` }, 'name', pkg); + engine.registry = registry; + rows.set(`page|home|__env__|active|${COPYING}`, { + id: 'r_page_home', type: 'page', name: 'home', organization_id: null, package_id: COPYING, state: 'active', + metadata: JSON.stringify({ name: 'home', label: ROW_TITLE }), + }); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any; + if (hydration === 'the cold boot') expect((await protocol.loadMetaFromDb()).errors, 'the boot hydrates the row').toBe(0); + else await protocol.getMetaItems({ type: 'page' }); + const bare = registry.bare('page', 'home'); + expect([bare?.label, bare?._packageId, bare?._provenance], `${hydration}: the bare entry`).toEqual([ROW_TITLE, COPYING, 'package']); + } + }); + } + }); }); }); diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts new file mode 100644 index 00000000000..a0014a5c1bf --- /dev/null +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22057 — every `registerItem(` call in `protocol.ts` carries a recorded + * disposition, so the closing card's classification cannot go stale when a + * registration is added, removed or re-scoped. + * + * `SchemaRegistry.registerItem(type, item, keyField)` with no package id + * writes the registry's bare slot for the name, and `SchemaRegistry.getItem` + * answers that slot ahead of every package's own entry, whichever package the + * read names. The question each row below answers is the card's: can this + * registration put a body bound to one package under a bare name that + * another package ships? Where it can for a VIEW, the registration first asks + * `anotherPackageShips` and skips the name (#21980's shape for an expansion), + * and the reads answer the body from its stored row. The behaviour is pinned + * in `protocol.org-scoped-write-refused.test.ts`, block (j). + * + * ⛔ View only. For every other type a row bound to one package, of a name two + * packages ship, holds the bare entry with its own body and envelope, as + * #4624 rules (`objectql`'s `protocol-boot-hydration-scoped.test.ts`, + * ADR-0048); block (j)'s control (e) pins that here. + * + * The population is the card's enumeration command, + * `git grep -n "registerItem(" -- packages/metadata-protocol/src/protocol.ts`: + * its matches are the calls below, mentions inside comments, and one + * `unregisterItem(`. Each call is keyed by the class member it sits in and its + * argument text, which survive line moves and fail on any change of scope. A + * new call, a removed one or a re-scoped one turns this red until its row is + * recorded here with its disposition (and the card's table in the PR that + * adds it). + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8'); +const LINES = SOURCE.split('\n'); +const CALLEE = 'registerItem('; + +/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */ +const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; +/** A line of a comment: a block comment's body or opener, or a line comment. */ +const COMMENT_LINE = /^\s*(?:\*|\/\*|\/\/)/; + +interface Site { readonly member: string; readonly args: string } +interface Located extends Site { readonly line: number; readonly headerLine: number } + +/** The member a source offset sits in: the nearest member header above it. */ +function memberAt(offset: number): { name: string; line: number } { + for (let line = SOURCE.slice(0, offset).split('\n').length - 1; line >= 0; line--) { + const header = MEMBER_HEADER.exec(LINES[line]); + if (header) return { name: header[1], line }; + } + return { name: '(no member)', line: -1 }; +} + +/** Every `registerItem(` call on a code line: not `unregisterItem(`, not inside a comment. */ +function callsOf(): Located[] { + const calls: Located[] = []; + for (let at = SOURCE.indexOf(CALLEE); at !== -1; at = SOURCE.indexOf(CALLEE, at + 1)) { + if (/[A-Za-z_$]/.test(SOURCE[at - 1] ?? '')) continue; + const line = SOURCE.slice(0, at).split('\n').length - 1; + if (COMMENT_LINE.test(LINES[line])) continue; + let end = at + CALLEE.length; + for (let depth = 1; depth > 0 && end < SOURCE.length; end++) { + if (SOURCE[end] === '(') depth++; + else if (SOURCE[end] === ')') depth--; + } + const args = SOURCE.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, ''); + const member = memberAt(at); + calls.push({ member: member.name, args, line, headerLine: member.line }); + } + return calls; +} + +type Disposition = 'no' | 'yes, view only: skips a name another package ships'; + +/** + * The recorded disposition of every call. `path`: the callers that reach it + * and the kernels it registers on. `why`: why it cannot put a body bound to + * one package under a name another package ships, or how it skips that name. + */ +const SITES: ReadonlyArray = [ + { + member: 'applyObjectRegistryMutation', args: 'request.type, request.item, \'name\'', disposition: 'no', + path: 'applyRegistryWriteThrough for `object` (a save, a publish, a rollback, a revert, a replica\'s mutation), on every kernel', + why: 'an object: the entry lands in the generic map, which no read serves an object from (getItem, listItems and ' + + 'getArtifactItem read the object contributors for `object`); an object has one code owner (ADR-0029 D3), ' + + 'and a row bound to another package is refused (D9.9, OBJECT_OVERLAY_PACKAGE_MISMATCH)', + }, + { + member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), \'name\' as any', + disposition: 'yes, view only: skips a name another package ships', + path: 'the list\'s hydration and the write-through on an unscoped kernel; the boot\'s loadMetaFromDb on every kernel', + why: 'a stored row: a view row bound to a package is not registered where another package ships its name, and the ' + + 'reads answer it from its row; a package-less row, and every other type, register as before (#4624)', + viewOnlyBy: 'const bound = canonicalType === \'view\' ? boundPackageOf(options.packageId) : undefined;', + }, + { + member: 'hydrateExpandedViewItems', args: 'type, item, \'name\' as any', + disposition: 'yes, view only: skips a name another package ships', + path: 'hydrateOverlayIntoRegistry, for each expansion of a stored view container', + why: 'an expansion: not registered where another package ships its name (#21980), and the by-name read answers it ' + + 'from its stored container row', + viewOnlyBy: 'if ((PLURAL_TO_SINGULAR[type] ?? type) !== \'view\') return [];', + }, + { + member: 'restoreArtifactRegistryView', args: 'type, baseline.data, \'name\'', + disposition: 'yes, view only: skips a name another package ships', + path: 'the delete\'s heal, tier 2 (deleteMetaItem, revertCommit\'s removal, a replica\'s removal), on an unscoped kernel', + why: 'the metadata service\'s baseline: a view baseline bound to a package is not re-registered where another ' + + 'package ships the name; tier 1 finds no bare entry to drop for a row the hydration skipped', + viewOnlyBy: 'const bound = canonicalMetaType(type) === \'view\'', + }, +]; + +const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`; + +describe('[#22057] every registerItem( in protocol.ts has a recorded disposition', () => { + const found = callsOf(); + + it('the calls in the source are exactly the recorded ones', () => { + const recorded = SITES.map(keyOf).sort(); + const inSource = found.map(keyOf).sort(); + expect( + inSource.filter((key) => !recorded.includes(key)), + 'a registration with no recorded disposition: classify it (can it put a body bound to one package under a ' + + 'bare name that another package ships?) and record it in SITES', + ).toEqual([]); + expect(recorded.filter((key) => !inSource.includes(key)), 'a recorded registration that is no longer in the source').toEqual([]); + expect(inSource, 'each call once').toEqual(recorded); + }); + + it('the population is the card\'s enumeration command: the calls, plus mentions in comments and unregisterItem(', () => { + // Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident. + expect(found).toHaveLength(SITES.length); + expect(found.filter((site) => site.member === '(no member)')).toEqual([]); + const callLines = new Set(found.map((site) => site.line)); + const others = LINES.map((text, line) => ({ text, line })) + .filter(({ text, line }) => text.includes(CALLEE) && !callLines.has(line)); + expect( + others.filter(({ text }) => !COMMENT_LINE.test(text) && !text.includes(`un${CALLEE}`)).map(({ text }) => text.trim()), + 'a line the command prints that is neither a recorded call, a comment, nor unregisterItem(', + ).toEqual([]); + // Every other spelling of the method on a code line is the hydrator's capability probe. + const spelled = LINES.filter((text) => !COMMENT_LINE.test(text) && /(? !text.includes(CALLEE)); + expect(spelled.map((text) => text.trim())).toEqual(['if (!registry || typeof registry.registerItem !== \'function\') return false;']); + }); + + it('each registration that skips a name another package ships asks anotherPackageShips before it registers', () => { + for (const site of SITES.filter((s) => s.disposition !== 'no')) { + const call = found.find((f) => keyOf(f) === keyOf(site)); + expect(call, keyOf(site)).toBeDefined(); + const body = LINES.slice(call!.headerLine, call!.line).join('\n'); + expect(body, `${site.member}: the question asked ahead of its registration`).toContain('this.anotherPackageShips('); + } + }); + + it('each skip is kept to view, so every other type registers as #4624 rules', () => { + for (const site of SITES.filter((s) => s.disposition !== 'no')) { + expect(site.viewOnlyBy, `${site.member}: the text that keeps it to view`).toBeDefined(); + expect(SOURCE.split(site.viewOnlyBy!).length - 1, `${site.member}: ${site.viewOnlyBy}`).toBe(1); + } + }); + + it('the question is the one predicate, over every package that can ship the name', () => { + expect(SOURCE).toMatch( + /\n {4}private anotherPackageShips\(type: string, name: string, own: unknown, shipping\?: ShippingPackages\): boolean \{\n {8}return this\.shippedArtifactsOf\(type, name, shipping\)\n/, + ); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 05c2a69909c..8de5265bea8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1819,6 +1819,15 @@ function envelopePackageId(requestedPackageId: string | undefined, served: unkno return requestedPackageId ?? (served as { _packageId?: string } | null | undefined)?._packageId; } +/** + * [#22057] The package a body is bound to, when it is bound to one: a + * non-empty id that is not the `'sys_metadata'` sentinel a package-less row + * carries. `undefined` for a package-less body. + */ +function boundPackageOf(packageId: unknown): string | undefined { + return typeof packageId === 'string' && packageId !== '' && packageId !== 'sys_metadata' ? packageId : undefined; +} + /** * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE * DERIVED, and therefore must never be persisted from a caller's body. @@ -18373,9 +18382,45 @@ export class ObjectStackProtocolImplementation implements * stays with the callers: that is a fact about the kernel this protocol * instance serves, not about the row in hand. * - * Returns whether anything was registered (org-scoped rows, bodies - * without a `name`, and registry doubles without `registerItem`, are - * no-ops). + * Returns whether the row is live in this registry's view: registered, or + * answered from its row as the section below states. Org-scoped rows, + * bodies without a `name`, and registry doubles without `registerItem` + * are no-ops and return `false`. The boot's page report reads it. + * + * ## [#22057] A VIEW row bound to one package, never under a name another ships + * + * The registry keeps one bare slot per name, and `SchemaRegistry.getItem` + * answers it ahead of every package's own entry, whichever package the read + * names. ADR-0005 gives that slot to a package-less row, which overlays the + * item of the name in every package. A view row bound to a package + * (ADR-0048) overlays that package's view alone, and under the bare name it + * answered every package's read of the name. Where another package also + * ships the name, the by-name read naming that package found no row of its + * own, expanded nothing, and served this row's body at the registry step, + * under the other package's envelope, while the list served that package's + * own view. Measured on an unscoped kernel after a save, and on either + * kernel after a cold boot (`loadMetaFromDb` hydrates through this method on + * every kernel). + * + * Such a view row is not registered, which is #21980's shape for an + * expansion ({@link hydrateExpandedViewItems}), and for the same reason it + * is safe there: no reader outside this class reads a view's bare entry. + * The reads answer the row from its row on every kernel + * ({@link findServedOverlayRow}): for its own package, and for a read naming + * no package. The list merges it into its package's slot. An aggregated + * container row still has its expansions judged one by one. The + * environment-scoped kernel's running answer was already this one, because + * it registers nothing on a save. + * + * ⛔ `view` only, judged on the canonical type. Every other type registers + * as before: a row bound to one package, of a name two packages ship, holds + * the bare entry with its own body and its own package's envelope (#4624, + * ADR-0048, pinned in `objectql`'s `protocol-boot-hydration-scoped.test.ts`). + * Registry readers that name no package read those entries (the declared + * security-metadata bootstraps, the action router, the picklist read), and + * which body they should see for such a name is not ruled here. A + * package-less view row, and a view row of a name only its own package + * ships or no package ships, register as before too. * * ## [#9111] `type` is an ASSERTED input, not a silently-trusted one * @@ -18486,6 +18531,14 @@ export class ObjectStackProtocolImplementation implements if (!data || typeof data !== 'object' || !('name' in data)) return false; const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; + // [#22057] See the header: a view row bound to one package is not + // registered under a name another package ships. Its expansions are + // judged by their own names. ⛔ View only. + const bound = canonicalType === 'view' ? boundPackageOf(options.packageId) : undefined; + if (bound !== undefined && this.anotherPackageShips(type, String((data as any).name), bound)) { + this.hydrateExpandedViewItems(type, data, options, registry); + return true; + } const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined); // [#16702] Say what this row IS before the artifact envelope is grafted // on top of it. Every body reaching this hydrator came out of a @@ -18920,7 +18973,9 @@ export class ObjectStackProtocolImplementation implements * stored row, ahead of the registry ({@link resolveRowlessExpandedView}), * for its own package and for a read that names none, and the list expands * the row itself. A name that only the container's own package ships, or - * that no package ships, is registered as before. + * that no package ships, is registered as before. [#22057] The question is + * {@link anotherPackageShips}, the one the container row's own + * registration asks too. */ private hydrateExpandedViewItems( type: string, @@ -18931,14 +18986,31 @@ export class ObjectStackProtocolImplementation implements let shipping: ShippingPackages | undefined; for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) { shipping ??= this.shippingPackagesOf(type); - const own = item._packageId; - const anotherShips = this.shippedArtifactsOf(type, String(item.name), shipping) - .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); - if (anotherShips) continue; + if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue; registry.registerItem(type, item, 'name' as any); } } + /** + * [#21980, #22057] True when a code package other than `own` ships an + * artifact named `name`: some artifact in {@link shippedArtifactsOf}, the + * one every package that can ship the name answers, has another + * `_packageId`. With `own` undefined, any shipped artifact is another + * package's. + * + * The registry's bare slot answers every package's read of the name, so + * each VIEW registration under a bare name asks this first: + * {@link hydrateExpandedViewItems} for an expansion, + * {@link hydrateOverlayIntoRegistry} for a view row bound to a package, + * and the delete's heal ({@link restoreArtifactRegistryView}) for a + * package's view it would re-register. ⛔ Only views ask it: see the + * hydrator's header for why every other type does not. + */ + private anotherPackageShips(type: string, name: string, own: unknown, shipping?: ShippingPackages): boolean { + return this.shippedArtifactsOf(type, name, shipping) + .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); + } + /** * [#4521] Write-through the SchemaRegistry after a mutation goes LIVE, so * a just-saved item is dispatchable — not merely listable. @@ -19064,6 +19136,14 @@ export class ObjectStackProtocolImplementation implements * MetadataService baseline (FilesystemLoader-sourced types) and * re-register it, preserving the historical refresh behaviour * for items the SchemaRegistry never held as artifacts. + * [#22057] Tier 1 also declines when there is no plain-key entry, + * which is what a VIEW row bound to one package leaves under a name + * another package ships ({@link hydrateOverlayIntoRegistry}). So a + * view baseline bound to one package, under a name another package + * ships, is not re-registered here: under the bare name it would + * answer every package's read of the name. The walk stops there, as + * for any baseline found. ⛔ View only, as at the hydration: every + * other type re-registers as before. * 3. [#5079] When NEITHER layer has anything, the deleted row was the * whole item — so the plain-key entry is retired too * ({@link SchemaRegistry.removeOverlayEntry}). @@ -19196,7 +19276,13 @@ export class ObjectStackProtocolImplementation implements const baseline = await this.readItemFromMetadataService(type, name); if (baseline.data !== undefined && baseline.data !== null) { if (this.environmentId === undefined) { - this.engine.registry.registerItem(type, baseline.data, 'name'); + // [#22057] See tier 2 in the header. ⛔ View only. + const bound = canonicalMetaType(type) === 'view' + ? boundPackageOf((baseline.data as { _packageId?: unknown })._packageId) + : undefined; + if (bound === undefined || !this.anotherPackageShips(type, name, bound)) { + this.engine.registry.registerItem(type, baseline.data, 'name'); + } } return; }