Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/23198-orphan-adoption-keeps-overlays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/metadata-protocol": patch
---

Adopting loose items into a base no longer moves an overlay of a packaged item, and the views it does move are served from the base at once

Clause-②: no

- `POST /api/v1/packages/ID/adopt-orphans` rebound every metadata row bound to no package into the base. That included a row that overlays an item a package ships, such as a list view edited and saved from the record list, or an edited copy of a package's view container. Once bound to the base, such a row stopped customising the packaged item. After a restart, an adopted overlay of a package's view container added views under its own name, and the package's own view was served again. An adopted overlay of one packaged view was listed twice under its name.
- ADR-0070 keeps an overlay of a packaged item bound to the item it customises, so the adoption now leaves such a row as it is stored. A row is skipped exactly when a save of it would be an overlay (`override-artifact`). An environment whose only rows bound to no package are such overlays now answers `reassignedCount: 0` and `success: false`.
- Each view row the adoption does rebind is now written through to the process registry with the binding it left. So `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read serve it from the base right away, not after the next restart.
- A view container's views are reconciled the way a publish reconciles them. What the row registered before the move counts as its own, so a later write of a row of that name bound to no package no longer removes the moved row's views.
- Unchanged: the request and the response shape (`success`, `reassignedCount`, `reassigned`, `targetPackageId`). Also unchanged: rows already bound to a package, organization-scoped rows, and rows of other types, which are rebound in storage only.
- There is no public export, signature or schema change.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
// The trace, measured on `origin/main` — all four routes fold at the producer
// ---------------------------------------------------------------------------
// `applyObjectRegistryMutation` has exactly ONE caller
// (`applyRegistryWriteThrough`), which has exactly FIVE:
// (`applyRegistryWriteThrough`), which has exactly SIX:
//
// 1. `saveMetaItem` passes `singularTypeForRepo`, and the method
// already ran `canonicalizeMetaRequestType`.
Expand All @@ -60,6 +60,12 @@
// so this caller folds rather than trusting
// the wire, even though every in-tree
// publisher emits the already-folded singular.
// 6. `reassignOrphanedMetadata` (#23198) folds the STORED row type at the
// call site through `canonicalMetaType`, and
// writes through only a row that folds to
// `'view'`. It never passes `'object'`, so it
// never reaches `applyObjectRegistryMutation`:
// an orphaned object is rebound in storage only.
//
// Both fold maps resolve the plural — `PLURAL_TO_SINGULAR.objects === 'object'`
// and `canonicalMetaUrlType('objects') === 'object'` — so no route can deliver
Expand Down Expand Up @@ -515,6 +521,24 @@ describe('[#8862] every object registry write-through registers under the singul
expect.objectContaining({ type: 'objects' }),
);
});

it('the orphan adoption writes a stored `views` row through as `view`, and an orphaned `objects` row not at all', async () => {
// An unscoped kernel: the only one on which a view row is written through.
const h = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map()) as any;
await seedRowVerbatim(protocol, { type: 'objects', name: 'ticket', body: objectBody('ticket'), state: 'active' });
await seedRowVerbatim(protocol, {
type: 'views', name: 'ticket_grid', state: 'active',
body: { name: 'ticket_grid', label: 'Tickets', type: 'grid', data: { provider: 'object', object: 'ticket' } },
});
h.registeredItems.length = 0;

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: PKG });

expect(res.reassigned).toEqual([{ type: 'objects', name: 'ticket' }, { type: 'views', name: 'ticket_grid' }]);
expect(h.registeredItems).toEqual([{ type: 'view', name: 'ticket_grid' }]);
expect(h.registeredObjects).toEqual([]);
});
});

// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -543,9 +567,9 @@ describe('[#8862] the `applyRegistryWriteThrough` caller set stays closed', () =
'utf8',
);

it('has exactly five call sites, all traced in this file’s header', () => {
it('has exactly six call sites, all traced in this file’s header', () => {
const callSites = source.match(/this\.applyRegistryWriteThrough\(/g) ?? [];
expect(callSites).toHaveLength(5);
expect(callSites).toHaveLength(6);
});

it('`applyObjectRegistryMutation` is reached only through the write-through', () => {
Expand Down
102 changes: 96 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,10 @@ type ShippingPackages = {
* revert, a replica's convergence, the boot, the list read). `binding` is
* the row's package binding, `undefined` for the package-less row, and
* `expansion` is everything that row's body expands to now.
* `reboundFrom` is set when the write moved the row from another binding
* (an orphan adoption): `reboundFrom.binding` is the binding it left,
* `undefined` for the package-less row. That binding's record moves to
* `binding`, so the row reconciles against what it registered under either.
* - `removed: 'every-row'`: a row of the name was removed (a reset, a delete,
* a revert's removal, a replica's removal). The heal is handed no binding,
* so it addresses every row of the name, and the set they imply is empty.
Expand All @@ -2001,9 +2005,29 @@ type ViewContainerWrite =
readonly container: string;
readonly binding: string | undefined;
readonly expansion: ReadonlyArray<Record<string, unknown>>;
readonly reboundFrom?: { readonly binding: string | undefined };
}
| { readonly container: string; readonly removed: 'every-row' };

/**
* What a hydration of one stored row is told about it:
* `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the
* expansion hydration it hands the row to.
*
* - `packageId`: the row's package binding.
* - `organizationId`: the row's scope, `null` for an environment-wide row
* (#6602).
* - `reboundFrom`: [#23198] set only when the write that is being hydrated
* moved the row from another binding (an orphan adoption). `packageId` is
* the binding it left, `null` for the package-less row. See
* {@link ViewContainerWrite}.
*/
type HydrationOptions = {
packageId?: string | null;
organizationId: string | null;
reboundFrom?: { readonly packageId: string | null };
};

/**
* ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope
* onto a returned item so artifact-level lock/packageId/provenance
Expand Down Expand Up @@ -19031,7 +19055,7 @@ export class ObjectStackProtocolImplementation implements
private async hydrateOverlayIntoRegistry(
type: string,
data: unknown,
options: { packageId?: string | null; organizationId: string | null },
options: HydrationOptions,
): Promise<boolean> {
// [#9111] See the header: the mint door asserts, the caller folds.
// Placed FIRST, ahead of every no-op return below, so the contract is
Expand Down Expand Up @@ -19525,12 +19549,15 @@ export class ObjectStackProtocolImplementation implements
private async hydrateExpandedViewItems(
type: string,
data: unknown,
options: { packageId?: string | null; organizationId: string | null },
options: HydrationOptions,
): Promise<void> {
await this.reconcileViewExpansions(type, {
container: String((data as { name?: unknown }).name),
binding: boundPackageOf(options.packageId),
expansion: this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true }),
...(options.reboundFrom === undefined
? {}
: { reboundFrom: { binding: boundPackageOf(options.reboundFrom.packageId) } }),
}, options.organizationId);
}

Expand All @@ -19552,7 +19579,9 @@ export class ObjectStackProtocolImplementation implements
* 2. Replace the record of the rows the write addresses with the names
* registered in step 1. The old names come out of the record before any
* heal runs, so a write that lands meanwhile reconciles against the new
* set.
* set. A rebind (an orphan adoption, {@link reassignOrphanedMetadata})
* addresses the row under both bindings: the record of the binding it
* left is removed, and its names count as the row's old names.
* 3. Heal each old name the new set lacks through the delete's per-name
* walk ({@link restoreArtifactRegistryView}): a packaged view of that
* name is un-shadowed, a MetadataService baseline is re-registered, and
Expand Down Expand Up @@ -19604,6 +19633,13 @@ export class ObjectStackProtocolImplementation implements
} else {
const row = write.binding ?? '';
for (const name of rows.get(row) ?? []) previous.add(name);
// A rebind: what the row registered under the binding it left is
// its own too, and that record row is gone with the binding.
if (write.reboundFrom !== undefined) {
const left = write.reboundFrom.binding ?? '';
for (const name of rows.get(left) ?? []) previous.add(name);
rows.delete(left);
}
if (registered.size > 0) rows.set(row, registered);
else rows.delete(row);
}
Expand Down Expand Up @@ -19717,6 +19753,8 @@ export class ObjectStackProtocolImplementation implements
* [#23198] Every caller awaits it: a republish of a view container heals
* the members it dropped ({@link reconcileViewExpansions}) before the
* write answers, so the reads that follow it serve the published set.
* The orphan adoption ({@link reassignOrphanedMetadata}) calls it for each
* view row it rebinds, with `reboundFrom`.
*/
private async applyRegistryWriteThrough(request: {
type: string;
Expand All @@ -19725,6 +19763,12 @@ export class ObjectStackProtocolImplementation implements
packageId?: string | null;
/** The row's org scope — `null` for an env-wide row. [#6602] */
organizationId: string | null;
/**
* [#23198] The binding the write moved the row from, when it moved it
* (an orphan adoption): `null` for the package-less row. See
* {@link HydrationOptions}.
*/
reboundFrom?: { readonly packageId: string | null };
}): Promise<void> {
// [#8862] The `|| request.type === 'objects'` limb is GONE — this
// branch admitted the plural that {@link applyObjectRegistryMutation}
Expand Down Expand Up @@ -19772,6 +19816,7 @@ export class ObjectStackProtocolImplementation implements
await this.hydrateOverlayIntoRegistry(request.type, request.item, {
packageId: request.packageId ?? undefined,
organizationId: request.organizationId,
...(request.reboundFrom === undefined ? {} : { reboundFrom: request.reboundFrom }),
});
} catch (err: any) {
// Best-effort, exactly like the object branch: the row is already
Expand Down Expand Up @@ -25938,8 +25983,27 @@ export class ObjectStackProtocolImplementation implements
* every such orphan to `targetPackageId` so the env converges on the
* package-first model and the "Local / Custom" migration scope can be
* retired. Owned rows (already bound to a real package) are left untouched.
* Updates the durable column; the in-memory registry picks the new binding
* up on the next metadata reload.
*
* ## [#23198] An overlay of an artifact item is not an orphan
*
* ADR-0070's kernel section: "Overlays of artifact items (`intent =
* override-artifact`, `allowOrgOverride`) are **unchanged** — they keep
* their binding to the packaged item they customise." An orphan is a
* runtime-created item. So a package-less row for which
* {@link isArtifactBacked} holds — the predicate {@link saveMetaItem} sets
* `intent: 'override-artifact'` by — is not adopted, and it stays as
* stored. Adopted, a package-less overlay of a packaged view container
* expanded under its own name in the base, so the packaged view lost the
* customisation and two views were added beside it; an overlay of one
* packaged view was served twice under its name. Both took effect at the
* next restart, and the registry kept the old answer until then.
*
* Each view row it does rebind goes through the write-through, with the
* binding it left ({@link applyRegistryWriteThrough}, `reboundFrom`), so
* the registry follows the row at once: a view container's expansions are
* reconciled for the new binding, and the record of the old one moves
* with it ({@link reconcileViewExpansions}). Rows of other types are
* rebound in storage only, as before.
*/
async reassignOrphanedMetadata(request: {
targetPackageId: string;
Expand All @@ -25966,7 +26030,10 @@ export class ObjectStackProtocolImplementation implements
// [#21911] The explicit system opt-in — see findServedOverlayRow.
const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[];
const orphans = rows.filter(
(r) => r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata',
(r) => (r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata')
// [#23198] An overlay of an artifact item keeps its binding
// (ADR-0070); see the header. The type folds as the save's does.
&& !this.isArtifactBacked(PLURAL_TO_SINGULAR[r.type] ?? r.type, String(r.name)),
);

const reassigned: Array<{ type: string; name: string }> = [];
Expand Down Expand Up @@ -26006,7 +26073,30 @@ export class ObjectStackProtocolImplementation implements
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
continue;
}
// [#23198] The registry follows the rebound row now, not at the
// next restart; see the header. ⛔ Views only.
const type = canonicalMetaType(String(row.type));
if (type !== 'view') continue;
let rebound: { name: string; data: unknown };
try {
// The row as the list read hydrates it, at its new binding.
[rebound] = this.storedOverlayEntries({ type }, [{ ...row, package_id: request.targetPackageId }]);
} catch (err: any) {
// Best-effort, as the write-through's own failure is: the row
// is rebound, and the next list read hydrates it.
console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`);
continue;
}
await this.applyRegistryWriteThrough({
type,
name: rebound.name,
item: rebound.data,
packageId: request.targetPackageId,
organizationId: null,
reboundFrom: { packageId: row.package_id ?? null },
});
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
Expand Down
Loading
Loading