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
18 changes: 18 additions & 0 deletions .changeset/23646-adopt-orphans-object-registry-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): an orphan adoption moves each adopted object's registry owner and definition to the target package in the same request (ADR-0070 D5)

Clause-②: no

`reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, rebinds every package-less `sys_metadata` row to the target package. For an object, only the stored row moved. The registry kept the owner that the package-less row had registered (the `sys_metadata` sentinel) and the pre-adoption definition until the next restart. Until then, a save of the object naming the target package answered success and never went live: its new fields were not on the data plane.

Each adopted object row now goes through the same registry write-through a save uses, in the same request. The package-less row's own registration leaves the registry first (`SchemaRegistry.unregisterObject`, the verb the delete heal already uses), so the object keeps exactly one owner (ADR-0029 D3). Right after the adoption, the target package owns the object, the data plane serves its definition stamped with that package, and the package's object list includes it. A save naming the target package then goes live at once, and the next boot registers the same owner.

What stays as before:

- The adoption's request and response shapes, and which rows it rebinds.
- A package-less row of an object that another package's row owns is rebound in storage only, and the registry keeps that owner. The next boot judges the two rows, as before.
- If another package extends the object, the registry refuses to drop the old owner. The adoption then logs one `error` naming the object, and the registry keeps the old owner until the next restart.
- Views follow their rebound row as before. Other types are rebound in storage only.
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@
// 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.
// `'view'` or [#23646] `'object'` — the latter
// once `releaseAdoptedObjectOwner` has handed
// the adopted row the name. A legacy row stored
// as `'objects'` therefore reaches
// `applyObjectRegistryMutation` as `'object'`.
// 7. `restoreArtifactRegistryView` (#23297) folds at the call site through
// `canonicalMetaType`, the key it also reads
// the stored rows under, and registers a row
Expand Down Expand Up @@ -528,7 +530,11 @@ describe('[#8862] every object registry write-through registers under the singul
);
});

it('the orphan adoption writes a stored `views` row through as `view`, and an orphaned `objects` row not at all', async () => {
// [#23646] The orphaned object used to be rebound in storage only, and
// this case pinned that (`registeredObjects` empty). It now pins the fold
// the adoption's object limb applies: a stored `objects` row registers
// under `object`, never under the plural it is stored as.
it('the orphan adoption writes a stored `views` row through as `view`, and a stored `objects` row as `object`', 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;
Expand All @@ -542,8 +548,9 @@ describe('[#8862] every object registry write-through registers under the singul
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([]);
expect(h.registeredItems).toEqual([{ type: 'object', name: 'ticket' }, { type: 'view', name: 'ticket_grid' }]);
expect(h.registeredItems).not.toContainEqual(expect.objectContaining({ type: 'objects' }));
expect(h.registeredObjects).toEqual(['ticket']);
});
});

Expand Down
98 changes: 94 additions & 4 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20527,7 +20527,8 @@ export class ObjectStackProtocolImplementation implements
* 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`.
* view row it rebinds, with `reboundFrom`, and [#23646] for each object
* row, once {@link releaseAdoptedObjectOwner} has handed it the name.
*/
private async applyRegistryWriteThrough(request: {
type: string;
Expand Down Expand Up @@ -27607,7 +27608,25 @@ export class ObjectStackProtocolImplementation implements
* 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
* with it ({@link reconcileViewExpansions}).
*
* ## [#23646] …and so does each object row it rebinds
*
* An object row used to be rebound in storage only. The registry kept the
* owner the package-less row had registered (the `'sys_metadata'`
* sentinel) and the pre-adoption definition until the next restart, and
* every save of the object naming the target package answered success
* without going live: the write-through's `own` claim under the target met
* the registry's single-owner rule (ADR-0029 D3) and landed in
* {@link applyObjectRegistryMutation}'s best-effort warn. Measured over
* `POST /api/v1/packages/:id/adopt-orphans` on a booted CRM example: the
* data plane refused the saved object's new field with `400 INVALID_FIELD`.
* So the sentinel's contribution leaves the registry first
* ({@link releaseAdoptedObjectOwner}), and the row registers at its new
* binding through the same write-through, in the same request; the next
* boot registers the same owner. Only an ACTIVE object row is registered,
* the rows the boot registers: an adopted draft is rebound in storage
* only, because a draft never reaches the runtime. Rows of other types are
* rebound in storage only, as before.
*/
async reassignOrphanedMetadata(request: {
Expand Down Expand Up @@ -27681,9 +27700,9 @@ export class ObjectStackProtocolImplementation implements
continue;
}
// [#23198] The registry follows the rebound row now, not at the
// next restart; see the header. ⛔ Views only.
// next restart; see the header. [#23646] A view, and an object.
const type = canonicalMetaType(String(row.type));
if (type !== 'view') continue;
if (type !== 'view' && type !== 'object') continue;
let rebound: { name: string; data: unknown };
try {
// The row as the list read hydrates it, at its new binding.
Expand All @@ -27694,6 +27713,16 @@ export class ObjectStackProtocolImplementation implements
console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`);
continue;
}
// [#23646] An object's one registry owner moves with its row. Only
// an active row's: the boot registers no other ({@link
// loadMetaFromDb}), and a draft never reaches the runtime.
if (
type === 'object'
&& (row.state !== 'active'
|| !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId))
) {
continue;
}
await this.applyRegistryWriteThrough({
type,
name: rebound.name,
Expand Down Expand Up @@ -27734,6 +27763,67 @@ export class ObjectStackProtocolImplementation implements
};
}

/**
* [#23646, ADR-0070 D5] Hand an adopted object's registry name over to its
* rebound row: answers whether {@link reassignOrphanedMetadata} registers
* the row at its new binding through {@link applyRegistryWriteThrough}.
*
* An object has ONE owner per name in the registry (ADR-0029 D3), and a
* package-less row registers as its owner under the `'sys_metadata'`
* sentinel (the save's write-through and the boot's hydration both key a
* package-less row so). A registration at the new binding while that owner
* stands is a second package's `own` claim, which `registerObject` refuses.
* So the sentinel's contribution leaves first, through
* `SchemaRegistry.unregisterObject` — the verb
* {@link restoreArtifactRegistryView} already hands an object's name to a
* remaining row with — and the write-through then registers the row as a
* save of it does. Both run before the adoption answers, with no `await`
* between them, so no request sees the name unowned. No new registry verb
* exists for this, and none is needed.
*
* | the name's registered owner | answer |
* |:--|:--|
* | the sentinel (the adopted row's own registration) | unregistered; `true` |
* | none | `true`: the row registers as the next boot would register it |
* | another package's row | `false`: the adopted row never held the name; the registry keeps that owner, as before, and the next boot judges both rows |
*
* A code-shipped object is never adopted ({@link isArtifactBacked} filters
* it out first), so a packaged owner does not reach this table.
*
* `unregisterObject` refuses an object another package extends (its
* ADR-0029 guard). The registry then keeps the sentinel's owner and the
* pre-adoption definition until the next restart while storage says
* otherwise, which is why that refusal is logged at `error`, once per
* object, with the consequence and the remedy.
*
* `false` for a registry with no object half (a metadata-only double) and
* for a body with no name, which register nothing.
*/
private releaseAdoptedObjectOwner(name: string, item: unknown, targetPackageId: string): boolean {
const registry: any = (this.engine as any)?.registry;
if (typeof registry?.registerObject !== 'function') return false;
if (!item || typeof item !== 'object' || typeof (item as { name?: unknown }).name !== 'string') return false;
const owner: { packageId?: unknown } | undefined = typeof registry.getObjectOwner === 'function'
? registry.getObjectOwner(name)
: undefined;
if (owner === undefined) return true;
if (owner.packageId !== 'sys_metadata' || typeof registry.unregisterObject !== 'function') return false;
try {
registry.unregisterObject(name);
return true;
} catch (err: any) {
console.error(
`[Protocol] reassignOrphanedMetadata: object '${name}' was adopted into package `
+ `'${targetPackageId}' in sys_metadata, but the registry keeps its package-less definition `
+ `(owner 'sys_metadata') until the next restart: ${err?.message ?? err}. Until then the data `
+ `plane serves the pre-adoption definition, and a save of the object naming '${targetPackageId}' `
+ 'answers success without going live. Fix: restart the server; the boot registers the object '
+ `under '${targetPackageId}'.`,
);
return false;
}
}

// ─────────────────────────────────────────────────────────────────────
// ADR-0067 — package-scoped commit history & rollback
// ─────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading