diff --git a/.changeset/21967-view-expansion-per-package.md b/.changeset/21967-view-expansion-per-package.md new file mode 100644 index 00000000000..7798183f66a --- /dev/null +++ b/.changeset/21967-view-expansion-per-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved + +Clause-②: no + +- **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form. +- **What it does now.** The view list serves each package its own item of such a name: + - a package's saved copy of the container serves that package's item of each name it expands; + - a package-less saved copy stands in for every package that has no copy of its own (ADR-0048); + - any other package keeps its own item. + + So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too. +- **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list. +- **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list. +- **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 46b3c624937..2e9c776e914 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -68,7 +68,7 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de **Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. -**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too, with one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. To close such a form at the endpoints, withdraw it in every saved environment-wide copy of that container as well. Reading each package's expansion separately is tracked in #21967. +**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too. **Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index 120014958ba..f5d1031c4e8 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -326,10 +326,9 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; * name in every package only when its layer holds every package's body of the * name. The organization-scoped write door anchors one body per package. The * env-wide view list the anonymous doors read holds one item per package of a - * name, with one exception: where a package's env-wide copy of a view - * container is saved, the list holds that copy's expansion alone for each form - * it expands, so the doors can miss another package's withdrawal of that - * form, whether saved or shipped (per-package expansion is #21967). A layer + * name: a package's saved env-wide copy of a view container serves that + * package's item of each form it expands, and a package-less copy stands in + * for every package with no copy of its own. A layer * with no body of the row, or whose body has no explicit withdrawal, withdraws * nothing, so a form published only in an organization stays open there. */ 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 3823b386eca..81bcde1743d 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 @@ -73,12 +73,12 @@ import { afterEach, describe, expect, it } from 'vitest'; // #5480 update). Imported from `@objectstack/metadata-core`, never from // `@objectstack/objectql`: objectql DEPENDS ON this package, so that import // would close a dependency cycle turbo rejects outright. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; // The anonymous form doors' own verdict (`registerFormEndpoints` in // `@objectstack/rest`), applied here to the protocol's real list reads. import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { SharingConfigSchema } from '@objectstack/spec/ui'; +import { expandViewContainer, SharingConfigSchema } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -1294,3 +1294,271 @@ describe('a publish that states no package, over a store that cannot be read', ( expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toEqual([]); }); }); + +// Each package's copy of a view container expands into that package's own +// slot. Two packages ship the container `task`, and the source registrars +// register its form `task.intake_form` once per package. An env-wide copy of +// the container stored for one package writes that package's item of each +// name it expands, and a package-less copy stands in for every package with +// no copy of its own (ADR-0048). So the env-wide view list the anonymous doors +// judge against holds every package's body of the form, and any package's +// withdrawal of it, shipped or saved, closes it. The by-name read naming a +// package serves the item that package's slot in the list serves. +describe('each package\'s copy of a view container expands into its own package\'s slot', () => { + const NAME = 'task.intake_form'; + const SLUG = 'shared-intake'; + const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` }); + // Each body of the form is told apart by its title. + const container = (allowAnonymous: boolean, title = 'Intake') => ({ + name: 'task', object: 'task', formViews: { intake_form: { title, sharing: sharing(allowAnonymous) } }, + }); + const formView = (allowAnonymous: boolean, title: string) => ({ + name: NAME, label: title, object: 'task', viewKind: 'form', config: { title, sharing: sharing(allowAnonymous) }, + }); + + /** + * The registry where these pins turn on it, held as the real + * `SchemaRegistry` holds it: each package's entries under + * `:` (the container and the views the source registrars + * expand from it), a row an unscoped kernel hydrates under the bare name, + * `getItem` bare slot first, and `getArtifactItem`'s package-scoped + * code-artifact lookup. `owner` is the code package that owns the object. + */ + function packagesRegistry(shipped: Array<[string, Record]>, owner?: string) { + const entries = new Map>(); + const register = (item: Record, packageId?: string) => { + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + } + entries.set(packageId ? `${packageId}:${String(item.name)}` : String(item.name), item); + }; + for (const [packageId, body] of shipped) { + register({ ...body, name: 'task' }, packageId); + for (const vi of expandViewContainer('task', body)) register({ ...(vi as any) }, packageId); + } + const composite = (name: string) => [...entries].filter(([key]) => key.endsWith(`:${name}`)).map(([, it]) => it); + return { + registerItem: (type: string, item: Record, _keyField?: string, packageId?: string) => { + if (type === 'view') register(item, packageId); + }, + listItems: (type: string, packageId?: string) => (type === 'view' + ? [...entries.values()].filter((it) => !packageId || it._packageId === packageId) + : []), + getItem: (type: string, name: string, packageId?: string) => (type !== 'view' + ? undefined + : entries.get(name) ?? (packageId ? entries.get(`${packageId}:${name}`) : undefined) ?? composite(name)[0]), + getArtifactItem: (type: string, name: string, packageId?: string) => { + if (type !== 'view') return undefined; + const shippedAs = composite(name).filter((it) => isCodeArtifactBody(it)); + return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; + }, + getPackagedObjectOwner: (name: string) => (owner && name === 'task' ? { packageId: owner, ownership: 'own' } : undefined), + getObject: () => undefined, + registerObject: () => {}, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + const KERNELS = [ + ['an environment-scoped kernel', 'env_prod'], + ['an unscoped kernel (write-through hydrates the registry)', undefined], + ] as const; + + function harness(shipped: Array<[string, Record]>, environmentId: string | undefined, owner?: string) { + const { engine, rows } = makeStubEngine(); + engine.registry = packagesRegistry(shipped, owner); + const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, environmentId) as any; + return { protocol, rows }; + } + + /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ + async function restoreOpenOverlay(protocol: any) { + await protocol.ensureOverlayIndex(); + await protocol.getOverlayRepo('org_a').put( + { type: 'view', name: NAME, org: 'org_a' }, + formView(true, 'Intake (org)'), + { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, + ); + } + + /** An env-wide copy of the container, stored for `packageId` (package-less when undefined). */ + async function saveCopy(protocol: any, packageId: string | undefined, allowAnonymous: boolean, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId ? { packageId } : {}), + })).success).toBe(true); + } + + /** Each body of the form in the env-wide view list: [package, allowAnonymous, title]. */ + async function envWideBodies(protocol: any): Promise> { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]).filter((v) => v?.name === NAME) + .map((v): [unknown, unknown, unknown] => [v._packageId ?? null, v.config?.sharing?.allowAnonymous, v.config?.title]) + .sort((x, y) => String(x[0]).localeCompare(String(y[0]))); + } + + /** + * What the anonymous doors serve for the slug, by their own composition + * (`registerFormEndpoints`): the organization's read, each open candidate + * judged over the env-wide view list beneath it. + */ + async function doorsServe(protocol: any): Promise { + const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) + .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); + } + + describe('(a) one package\'s env-wide copy is saved with the form open, and another package ships it withdrawn', () => { + for (const [kernel, environmentId] of KERNELS) { + for (const owner of [undefined, 'pkg_a'] as const) { + const where = `${kernel}, ${owner ? 'the copy\'s package owns the object' : 'no code package owns the object'}`; + it(`${where}: the env-wide list holds the shipped withdrawal beside the copy, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId, owner); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a copy)'], + ['pkg_b', false, 'Intake'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + + it('control: with no package withdrawing the form, the doors serve the overlay', async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], 'env_prod'); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + expect((await doorsServe(protocol)).length).toBeGreaterThan(0); + }); + }); + + describe('(b) the same, with the withdrawal saved in the other package\'s own env-wide copy', () => { + for (const [kernel, environmentId] of KERNELS) { + for (const order of [['pkg_a', 'pkg_b'], ['pkg_b', 'pkg_a']] as const) { + it(`${kernel}, ${order[0]}'s copy saved first: each copy serves its own package's slot, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + for (const pkg of order) await saveCopy(protocol, pkg, pkg === 'pkg_a', `Intake (${pkg} copy)`); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a copy)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + }); + + describe('(c) a package-less env-wide copy stands in for every package with no copy of its own', () => { + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: the package-less copy serves each package's slot, so the administrator's switch opens the form for every package`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, undefined, true, 'Intake (env-wide copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide copy)'], + ['pkg_b', true, 'Intake (env-wide copy)'], + ]); + expect((await doorsServe(protocol)).length).toBeGreaterThan(0); + }); + + for (const order of [[undefined, 'pkg_b'], ['pkg_b', undefined]] as const) { + it(`${kernel}, ${order[0] ?? 'the package-less'} copy saved first: a package's own copy serves that package's slot ahead of the package-less copy`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + for (const pkg of order) { + await saveCopy(protocol, pkg, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); + } + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide copy)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + }); + + describe('(d) the list\'s slot for a package and the by-name read naming that package serve the same item', () => { + const projection = (v: any) => (v ? { name: v.name, viewKind: v.viewKind, config: v.config, _packageId: v._packageId } : v); + + /** For each package: one item in the env-wide list, and the by-name read and the list scoped to the package serve it. */ + async function expectAgreement(protocol: any, titles: Record) { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + for (const [pkg, title] of Object.entries(titles)) { + const slot = (envWide.items as any[]).filter((v) => v?.name === NAME && v._packageId === pkg); + expect(slot.map((v) => v.config?.title), `${pkg}: its one item in the env-wide list`).toEqual([title]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: pkg })).item; + expect(projection(byName), `${pkg}: the by-name read naming the package`).toEqual(projection(slot[0])); + const scoped: any = await protocol.getMetaItems({ type: 'view', packageId: pkg }); + expect((scoped.items as any[]).filter((v) => v?.name === NAME).map(projection), `${pkg}: the list scoped to the package`) + .toEqual([projection(slot[0])]); + } + } + + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: a name two packages' own copies expand`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + await expectAgreement(protocol, { pkg_a: 'Intake (pkg_a copy)', pkg_b: 'Intake (pkg_b copy)' }); + }); + + it(`${kernel}: a name a package-less copy expands, standing in for each package`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await saveCopy(protocol, undefined, true, 'Intake (env-wide copy)'); + await expectAgreement(protocol, { pkg_a: 'Intake (env-wide copy)', pkg_b: 'Intake (env-wide copy)' }); + }); + } + }); + + // A stored row under exactly the form's name is that name's own row + // (ADR-0005), and it is the row of its own package's slot (ADR-0048): it + // keeps another package's copy out of that package's slot only when it is + // package-less, as the by-name read naming that package decides. + describe('(e) a stored row of the form\'s own name serves its own package\'s slot, and hides no other package\'s copy', () => { + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: one package's row of the name and another package's withdrawing copy are both in the env-wide list, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + expect((await protocol.saveMetaItem({ + type: 'view', name: NAME, item: formView(true, 'Intake (pkg_a row)'), packageId: 'pkg_a', + })).success).toBe(true); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a row)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: 'pkg_b' })).item; + expect([byName?.config?.title, byName?.config?.sharing?.allowAnonymous]).toEqual(['Intake (pkg_b copy)', false]); + }); + + it(`${kernel}, control: a package-less row of the name serves every package's slot, ahead of any copy`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + expect((await protocol.saveMetaItem({ + type: 'view', name: NAME, item: formView(true, 'Intake (env-wide row)'), + })).success).toBe(true); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide row)'], + ['pkg_b', true, 'Intake (env-wide row)'], + ]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: 'pkg_b' })).item; + expect(byName?.config?.title).toBe('Intake (env-wide row)'); + }); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ae59646ef9d..def1f5213f3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2003,6 +2003,58 @@ function servedOverlayRowCandidates(address: { packages.map((packageId) => ({ ...scope, spelling, packageId })))); } +/** + * [#21967, ADR-0048] The package dimension of + * {@link servedOverlayRowCandidates}, in its order: with a package, that + * package (`packageId`) and then the package-less rows (`null`); with none, + * `undefined`, which matches a row of any package. + */ +function addressPackages(packageId: string | undefined): ReadonlyArray { + return [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) + .map((candidate) => candidate.packageId))]; +} + +/** + * [#21967, ADR-0048] The stored view container expansion that serves `name` + * at the address of `packageId`, among `expansions` + * ({@link ObjectStackProtocolImplementation.expandStoredViewContainers}, each + * paired with the container row it came from); `undefined` when none does. + * + * The package order is the package dimension of + * {@link servedOverlayRowCandidates}, the one prefer-local resolution: with a + * package, the expansion of that package's own container row first, then the + * expansion of a package-less one, which stands in for every package with no + * container row of its own, and never another package's; with none, any + * expansion. Within one package the last expansion of the name wins, as + * before (two containers of one package that expand one name are refused at + * the save door). + * + * Both doors select through this function: the list read + * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each + * package's slot of the name, and the by-name read + * ({@link ObjectStackProtocolImplementation.resolveRowlessExpandedView}) for + * the package it names. So a package's slot in the list and the by-name read + * naming that package serve the same expansion. Before, the list upserted the + * last expansion of a name over every package's item of it, so one package's + * stored copy of a container displaced another package's item of each name + * the copy expands, a withdrawn public form included. + */ +function servedViewExpansion( + expansions: ReadonlyArray<{ item: Record; container: E }>, + name: string, + packageId: string | undefined, +): { item: Record; container: E } | undefined { + for (const pkg of addressPackages(packageId)) { + let served: { item: Record; container: E } | undefined; + for (const expanded of expansions) { + if (expanded.item.name !== name) continue; + if (pkg === undefined || (expanded.container.packageId ?? null) === pkg) served = expanded; + } + if (served !== undefined) return served; + } + return undefined; +} + /** * ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list. * @@ -9051,9 +9103,10 @@ export class ObjectStackProtocolImplementation implements // override for it (ADR-0005 keys an overlay by its own name); // an expansion fills only a name with no row of its own. The // test is {@link namesWithOwnStoredRow} over this caller's - // `records`, the one the by-name read asks, so the two doors - // answer the same row for the name. An item the registry or a - // package supplies under the name is still replaced, as before. + // `records` at the slot's package ([#21967]), the one the + // by-name read asks, so the two doors answer the same row for + // the name. An item the registry or a package supplies under the + // name is still replaced, as before. // // [#21817] In a list scoped to a package, a package-less row // of the name stands in ahead of the expansion too: the by-name @@ -9061,47 +9114,120 @@ export class ObjectStackProtocolImplementation implements // expansion. The expansion still seats the slot, so a stand-in // held back by the merge is served there, as the package's. // - // [#21934] Only a name an expansion writes is upserted by name. - // Every other name keeps what the package-aware merge seated for - // it: one item per package that ships the name (ADR-0048), as the + // [#21934] Only a name an expansion writes is upserted. Every + // other name keeps what the package-aware merge seated for it: + // one item per package that ships the name (ADR-0048), as the // list serves it when no row is stored. The env-wide list is the // layer the anonymous form doors judge a withdrawal against, so // it holds every package's body of a name. - if (isView && records.length > 0) { - const ownRowNames = this.namesWithOwnStoredRow(records); + // + // [#21967] …and a name an expansion writes is upserted per + // package, never over every package's item of the name. Each + // slot of the name (the package of an item listed under it, or + // of a container row that expands it) serves + // {@link servedViewExpansion} for that package: the expansion of + // the package's own container row, else of a package-less one, + // which stands in for every package with no container row of its + // own (ADR-0048), as the by-name read naming the package serves + // it. A slot neither reaches keeps its item. Before, the last + // expansion of the name replaced every package's item of it, so + // one package's stored copy of a container displaced another + // package's item of each name the copy expands, and the + // anonymous form doors could miss that package's withdrawal of a + // form, shipped or saved. In a list scoped to a package the + // package-less container rows stand in the same way, in the + // slots the package seats only: a stand-in never seats a slot + // ([#21817]). + if (isView && (records.length > 0 || standInRows.length > 0)) { + // [#21510, #21967] The names a stored row of its own holds + // at a slot's package ({@link namesWithOwnStoredRow}, the + // predicate the by-name read asks for the package it + // names): an expansion never displaces such a row, and a + // row of one package keeps no other package's slot. + const ownRowNamesAt = new Map>(); + const ownRowNames = (pkg: string | undefined): ReadonlySet => { + let names = ownRowNamesAt.get(pkg); + if (names === undefined) ownRowNamesAt.set(pkg, (names = this.namesWithOwnStoredRow(records, pkg))); + return names; + }; const standInNames = this.namesWithOwnStoredRow(standInRows); - const expansions = this.expandStoredViewContainers(request.type, overlays) - .filter(({ item: vi }) => !ownRowNames.has(vi.name as string)); + const expansions = this.expandStoredViewContainers( + request.type, + packageId ? [...overlays, ...this.storedOverlayEntries(request, standInRows)] : overlays, + ); const written = new Set(expansions.map(({ item: vi }) => vi.name as string)); - const byName = new Map(); - for (const it of items as any[]) { - if (it && typeof it === 'object' && typeof it.name === 'string' && written.has(it.name)) { - byName.set(it.name, it); - } - } - for (const { item: vi } of expansions) { - const held = byName.get(vi.name as string) as Record | undefined; - if (held !== undefined && standInNames.has(vi.name as string)) { - // Seated: a copy the `unseated` record does not hold, - // stamped as the merge stamps a stand-in. - if (unseated?.has(held)) { - byName.set(vi.name as string, held._packageId === undefined ? { ...held, _packageId: packageId } : { ...held }); + const boundNames = new Set(expansions + .filter(({ container }) => container.packageId !== undefined) + .map(({ item: vi }) => vi.name as string)); + // The package of an item's slot, as the package-aware merge + // keys it; a list scoped to a package seats every item in + // that package's slot. + const slotPackage = (it: Record): string | undefined => packageId + ?? (typeof it._packageId === 'string' && it._packageId !== '' ? it._packageId : undefined); + const slotKey = (name: string, pkg: string | undefined) => `${name}\u0000${pkg ?? ''}`; + const filled = new Set(); + const filledNames = new Set(); + const serve = ( + name: string, + pkg: string | undefined, + held: Record | undefined, + ): Record | undefined => { + filled.add(slotKey(name, pkg)); + filledNames.add(name); + const served = servedViewExpansion(expansions, name, pkg); + // [#21817] In a list scoped to a package, a package-less + // row of the name stands in ahead of the expansion too: + // the by-name read naming the package serves that row + // before it asks any expansion. The package's own + // expansion still seats the slot, so a stand-in held back + // by the merge is served there, as the package's: a copy + // the `unseated` record does not hold, stamped as the + // merge stamps a stand-in. + if (held !== undefined && standInNames.has(name)) { + if (unseated?.has(held) && served !== undefined && served.container.packageId !== undefined) { + return held._packageId === undefined ? { ...held, _packageId: pkg } : { ...held }; } - continue; + return held; } - byName.set(vi.name as string, vi); - } + if (served === undefined || ownRowNames(pkg).has(name)) return held; + const own = served.container.packageId; + if (own !== undefined) filled.add(slotKey(name, own)); + // A package-less container's expansion standing in for a + // package carries that package's provenance, as a + // package-less row standing in does + // ({@link mergePackageAwareOverlay}), so the last pass + // below grafts that package's artifact envelope on it, as + // the by-name read naming the package does. + return pkg !== undefined && own === undefined ? { ...served.item, _packageId: pkg } : served.item; + }; const merged: unknown[] = []; for (const it of items as any[]) { if (!it || typeof it !== 'object' || typeof it.name !== 'string') continue; if (!written.has(it.name)) { merged.push(it); - } else if (byName.has(it.name)) { - merged.push(byName.get(it.name)); - byName.delete(it.name); + continue; } + const pkg = slotPackage(it); + if (filled.has(slotKey(it.name, pkg))) continue; + merged.push(serve(it.name, pkg, it)); } - items = [...merged, ...byName.values()]; + // A slot no listed item holds. A package's own container + // row's expansion seats that package's slot of the name. A + // package-less container's expansion of a name nothing else + // serves is listed on its own, in a list scoped to no package + // only. + for (const { item: vi, container } of expansions) { + const name = vi.name as string; + const own = container.packageId; + if (own !== undefined) { + if (filled.has(slotKey(name, own))) continue; + } else if (packageId !== undefined || filledNames.has(name) || boundNames.has(name)) { + continue; + } + const out = serve(name, own, undefined); + if (out !== undefined) merged.push(out); + } + items = merged; } // Only hydrate the global registry for unscoped (control-plane) @@ -9610,12 +9736,13 @@ export class ObjectStackProtocolImplementation implements /** * [#21442] Every item the stored view containers in `overlays` expand, in - * the order the list read upserts them by name (a later expansion of a - * name replaces an earlier one), each paired with the stored row it was - * expanded from. The one expansion pass both doors run: the list read - * serves the items, and {@link resolveRowlessExpandedView} also needs the - * row. Each container goes through {@link expandRuntimeViewContainer}, - * unchanged. + * row order, each paired with the stored row it was expanded from. The one + * expansion pass both doors run: the list read serves the items, and + * {@link resolveRowlessExpandedView} also needs the row. Each container + * goes through {@link expandRuntimeViewContainer}, unchanged. [#21967] + * Which of them serves a name at a package's address is + * {@link servedViewExpansion}'s answer, for both doors: within one package + * a later expansion of a name replaces an earlier one. */ private expandStoredViewContainers( type: string, @@ -9645,11 +9772,23 @@ export class ObjectStackProtocolImplementation implements * so a name that has a row in one organization only is row-less for every * other caller. ⛔ Never a second test of "this name has its own row": * two tests are two rules, and the doors would disagree again. + * + * [#21967] …and both pass the package whose slot they fill, so a name that + * has a row in one package only is row-less for every other package's + * slot. With `packageId`, a row counts when it is bound to that package or + * package-less, the package dimension of {@link servedOverlayRowCandidates} + * (a package-less row stands in for every package, ADR-0048); with none, + * every row counts. Before, the list asked with no package for every slot, + * so one package's row of a name kept every other package's container + * expansion of it out of the list, while the by-name read naming that + * other package served the expansion. */ - private namesWithOwnStoredRow(records: readonly any[]): ReadonlySet { + private namesWithOwnStoredRow(records: readonly any[], packageId?: string): ReadonlySet { + const packages = addressPackages(packageId); const names = new Set(); for (const record of records) { - if (typeof record?.name === 'string') names.add(record.name); + if (typeof record?.name !== 'string') continue; + if (packages.some((pkg) => pkg === undefined || (record?.package_id ?? null) === pkg)) names.add(record.name); } return names; } @@ -9674,8 +9813,13 @@ export class ObjectStackProtocolImplementation implements * ({@link readActiveOverlayRows}, same `packageId`, same gated `orgId`), * the same parse ({@link storedOverlayEntries}) and the same expansion * ({@link expandStoredViewContainers} over - * {@link expandRuntimeViewContainer}), the last expansion of the name - * winning as it does in the list. Nothing is persisted or registered: an + * {@link expandRuntimeViewContainer}), selected for the address by the + * same function ({@link servedViewExpansion}). [#21967] Naming a package, + * that is the package's slot in the list: the expansion of the package's + * own container row, else of a package-less one, which stands in (the + * package-less rows are read as the list scoped to the package reads + * them); naming none, any expansion of the name, the last one winning. + * Nothing is persisted or registered: an * expansion is derived from its container on every read, so there is no * second copy to drift from it (ADR-0005 keys an overlay by its own name). * ⛔ No kernel-specific branch — every kernel answers through this path. @@ -9693,23 +9837,32 @@ export class ObjectStackProtocolImplementation implements ): Promise { if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'view') return undefined; let records: any[] = []; + // [#21967] Naming a package, the package-less rows in scope stand in, + // read as the list scoped to that package reads them (the + // package-agnostic read, filtered back to the package-less rows). + let standInRows: any[] = []; try { records = await this.readActiveOverlayRows( { type: request.type, ...(request.packageId ? { packageId: request.packageId } : {}) }, orgId, ); + if (request.packageId) { + standInRows = (await this.readActiveOverlayRows({ type: request.type }, orgId)) + .filter((row) => (row?.package_id ?? null) === null); + } } catch (error) { // [#5532] The list read's rule: only an unprovisioned store means // "no rows". Any other failure is not answered as "nothing expands // this name". this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - if (this.namesWithOwnStoredRow(records).has(request.name)) return undefined; - let found: RowlessExpandedView | undefined; - for (const expanded of this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, records))) { - if (expanded.item.name === request.name) found = expanded; - } - return found; + const rows = [...records, ...standInRows]; + if (this.namesWithOwnStoredRow(rows, request.packageId).has(request.name)) return undefined; + return servedViewExpansion( + this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, rows)), + request.name, + request.packageId, + ); } /** @@ -19206,8 +19359,9 @@ export class ObjectStackProtocolImplementation implements * * Both read doors give a name with a stored row of its own that row * (#21510's one predicate, {@link namesWithOwnStoredRow}), and fill a - * row-less name with an expansion, the last one read winning - * ({@link expandStoredViewContainers}). So a container whose ROW name is + * row-less name with an expansion, the last one read winning within a + * package's slot and on a by-name read that names no package + * ({@link servedViewExpansion}). So a container whose ROW name is * served from elsewhere hides that view on both doors and, being no view * itself, leaves no read answering a view under the name; a container * whose EXPANSION takes such a name replaces that view on both doors with