Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/33127-stored-row-own-package-slot-semantic-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): the protocol upgrade guide names what each package's address answers when two packages each hold a stored row of one name: that package's own row, in every save order, before and after a restart (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

The semantic entry `stored-row-own-package-slot` (`packages/spec/src/migrations/entries/semantic/18.stored-row-own-package-slot.ts`) registers the narrowing `.changeset/33127-stored-row-own-package-slot.md` declares (ADR-0087, `adr-0087: registered`): no automatic rewrite, since every stored row keeps its shape and its binding. It adds no key and no export.
24 changes: 24 additions & 0 deletions .changeset/33127-stored-row-own-package-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
---

fix(objectql,metadata-protocol)!: a stored row bound to a package lives in that package's own registry slot, so each package's address serves its own row in every save order, before and after a restart (ADR-0150 D3/D4)

Clause-②: yes (narrowing) — one served answer goes away: an action that two packages hold only as stored rows was served by the read naming no package after a restart (the row hydrated last); it now refuses `409 RESOURCE_CONFLICT` naming both, as every other type the registry reads by package already did (#32607). The widening is on `SchemaRegistry`, exported from `@objectstack/objectql`: `registerItem` takes an optional fifth argument `{ storedRowPackage }`, and one new public method `removePackageOverlaySlots(type, name, member, select)`; and four reads that answered a shipped item, a 404 or a 409 now answer the address's own stored row (below).
Metadata-protocol: semantic
Skills-docs: none — `skills/**` and `content/docs/**` teach no reading of a name two packages each store a row of (grepped `?package=`, `.custom`, `409`: the hits are the pair's save door, a flow's secret refusal and a permission grant), so no taught sentence moves; the read naming no package keeps its `409` and its `?package=` remedy.

<!-- adr-0087: registered stored-row-own-package-slot -->

**BREAKING** for a reader of an action that two packages hold only as stored rows, by the read naming no package (`GET /api/v1/meta/action/NAME`), after a restart: it refused before the restart and served the row hydrated last after it; it now refuses `409 RESOURCE_CONFLICT` naming both packages, before and after. The fix: read each package's action by its package (`?package=ID`).

The registry kept ONE slot per name for a stored row (`registerItem` with no package), so the row saved or hydrated last took it. Each stored row of a type the registry reads by package (action, tool, agent, skill, webhook, api, email template, picklist, mapping, dataset; flow and permission keep their own slot rules) that is bound to a package — its own, or its `.custom` pair's by the pair-row rule — now lives in that package's own overlay slot, beside the package's code artifact, which it never overwrites.

- **#33127 — two packages ship a name and each stores its own row.** At boot (`loadMetaFromDb`) the row hydrated last took the name's slot: that package's registry item and `?package=` read answered its row, the other package's answered its shipped item, which package by row order (tool, webhook, agent, skill measured). Each package now answers its own row in every registration and row order; the read naming no package still refuses `409` naming both.
- **#32663 — the writer's registry item.** After alpha then beta saved a row of one name, both replicas' registry items answered `[null, beta's row]` at the two packages (the mirror in the other order) while the exact reads, row-served, answered each package's own row. The registry now agrees with the exact reads on the writer and on the peer (action, tool, email template). A `dashboard` is converged by name, unchanged.
- **#32662 — a pair address of a name held only as stored rows.** `?package=ID.custom` answered `404` for both packages before a restart and, after it, `200` for the row hydrated last and `409` naming one package for the other (dashboard, tool, action). Each pair address now answers its base package's row, before and after.
- **#32662 scope note (b).** A package's row beside its own pair's undeclared row: the read naming no package served the pair's row when it was saved first while `/published` served the package's own row. Both now serve the package's own row in either order.
- **#32900.** A module's locale member stored in its App's pair (declaring the module) beside a package-less row of the same locale: the module's registry item at that locale followed the row order. It now answers the module's own pair row in both orders; the install and seal answers are unchanged.

Unchanged, pinned as CONTROLs: a name one package holds (its row in its slot, its exact read and its read naming no package); two packages with one stored row (the other keeps its shipped item); a package-less row (it keeps the bare slot); the read naming no package of a two-holder name (`409` naming both).
28 changes: 27 additions & 1 deletion packages/metadata-protocol/src/protocol-exact-qualifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export interface ExactQualifierAddress {
* when only a stored row bound to the package carries it.
*/
readonly registryHolders: readonly string[];
/**
* [#32662, ADR-0150 D3] The one base a customization pair's address was
* admitted at through that base's stored row, when the registry holds the
* name for no base it pairs and the pair stores no row of its own: the
* package whose stored content the address serves. Absent otherwise.
*/
readonly storedRowBase?: string;
}

/** The engine's registry, as the reads below probe it. */
Expand Down Expand Up @@ -278,8 +285,9 @@ function refusal(
* `getEnvironmentScopedItem`, `getPackageScopedItem`).
*/
export async function admitExactQualifier(
host: Pick<ProtocolHost, 'engine' | 'findServedOverlayRow'>,
host: Pick<ProtocolHost, 'engine' | 'findServedOverlayRow' | 'packagelessAddress'>,
request: { type: string; name: string; packageId?: string; member?: string },
options: { readonly storedRowBases?: boolean } = {},
): Promise<ExactQualifierAddress | undefined> {
const packageId = request.packageId;
if (!packageId || !isPackageScopedType(request.type)) return undefined;
Expand Down Expand Up @@ -321,6 +329,24 @@ export async function admitExactQualifier(
}
}

// [#32662, ADR-0150 D3, red line 2] A pair address of a name its bases
// hold only as stored rows is judged from the rows' bindings, as the read
// naming no package judges such a name ({@link servedRowHolderOf}), never
// from the registry alone: a type the registry keeps one bare slot per
// name for (`dashboard`) holds the row hydrated last, so after a reboot
// the later saver's pair address answered its row and the earlier one's
// was refused naming only the other. Admitted at every base whose row is
// stored, one base read for its content ({@link ExactQualifierAddress.storedRowBase});
// two are refused naming both by {@link exactQualifierItem}, as two
// registry bases are. Asked by the plain item read (`options.storedRowBases`).
if (options.storedRowBases === true) {
const bindings = (await host.packagelessAddress(request.type, request.name, request.member, 'active')).candidates;
const rowBases = bindings.filter((binding) => binding !== packageId && pairsOf(binding, installed).includes(packageId));
if (rowBases.length > 0) {
return { packageId, registryHolders: rowBases, ...(rowBases.length === 1 ? { storedRowBase: rowBases[0] } : {}) };
}
}

const candidates: PackageScopedHolder[] = holders.map((holder) => ({ kind: 'package' as const, packageId: holder }));
if (registry.getEnvironmentScopedItem(request.type, request.name, request.member) !== undefined) {
candidates.push({ kind: 'environment' });
Expand Down
15 changes: 13 additions & 2 deletions packages/metadata-protocol/src/protocol-meta-item-face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ async function storedRowHoldersRefusal(
every: readonly string[],
activeRows: Awaited<ReturnType<ServedRowHost['readItemActiveRows']>>,
bound: readonly string[],
): Promise<{ kind: 'refused'; error: PackageScopedNameRefusedError } | undefined> {
): Promise<{ kind: 'served'; packageId: string } | { kind: 'refused'; error: PackageScopedNameRefusedError } | undefined> {
const registry: any = host.engine?.registry;
if ([...shippingPackageIds(host.shippedArtifactsOf(type, name))].some((id) => registry.isPackageDisabled(id) !== true)) return undefined;
const installed = installedManifestsOfEngine(host.engine);
Expand All @@ -288,7 +288,18 @@ async function storedRowHoldersRefusal(
&& known.some((holder) => holder !== row.package_id && pairsOf(holder, installed).includes(row.package_id as string));
const holding = await host.packagelessAddress(type, name, member, 'active', activeRows.filter((row) =>
!host.declinesStoredRow(type, name, row) && !pairOfAnother(row)));
if (holding.unbound || holding.candidates.length < 2) return undefined;
if (holding.unbound) return undefined;
// [#32662 scope note (b), ADR-0150 D3] One holder whose rows are its own
// and its customization pair's: the read is that holder's, at its address,
// as `/published` reads it, so the layer served is its own row in both
// save orders. Read as it was, it served the row stored first.
if (holding.candidates.length === 1) {
const [holder] = holding.candidates;
const ownAndPair = bound.length > 1 && bound.includes(holder)
&& bound.every((id) => id === holder || pairsOf(holder, installed).includes(id));
return ownAndPair ? { kind: 'served', packageId: holder } : undefined;
}
if (holding.candidates.length < 2) return undefined;
const base = {
status: 'refused' as const,
reason: 'ambiguous-in-environment' as const,
Expand Down
8 changes: 5 additions & 3 deletions packages/metadata-protocol/src/protocol-meta-item-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
// checked against it once they have ({@link exactQualifierItem}).
const exactAddress = await admitExactQualifier(host, {
type: request.type, name: request.name, packageId: request.packageId, member,
});
}, { storedRowBases: true });
let item: unknown;
// [ADR-0131 D6] Environment → code: the environment's stored row,
// else the code packages' definition. No organization's row is read —
Expand Down Expand Up @@ -763,9 +763,11 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
// [#28238] The content, though, is {@link packagelessContentPackage}'s:
// the code package that ships the name is a candidate too, so a row
// another package stored under the name is not served for it.
const contentPackageId = request.packageId || (packagelessAddressed
// [#32662] A pair address admitted at the one base whose stored row holds
// the name serves that base's stored content ({@link admitExactQualifier}).
const contentPackageId = exactAddress?.storedRowBase ?? (request.packageId || (packagelessAddressed
? packagelessContentPackage(packagelessAddressed, host.shippedArtifactsOf(request.type, request.name))
: undefined);
: undefined));

// ADR-0033 draft-overlay preview (non-strict): when the caller opts in
// (admin-gated upstream), prefer a `state='draft'` row if one exists, else
Expand Down
55 changes: 50 additions & 5 deletions packages/metadata-protocol/src/protocol-registry-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { storedRowDocument } from './item-lock.js';
import { installedManifestsOf, isPairOf, isTenantAuthored, itemDiscriminator } from '@objectstack/metadata-core';
import type { InstalledPackageSource } from '@objectstack/metadata-core';
import { isPackageScopedType } from '@objectstack/core';
import { PACKAGE_SLOTTED_STORE_TYPES } from '@objectstack/metadata';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui';
import { bindStoredRow } from './stored-row-binding.js';
Expand Down Expand Up @@ -226,6 +227,28 @@ function namesNoHolderAmongMany(
return !holders.some((holder) => isPairOf(bound, holder, installed));
}

/**
* [#33127, ADR-0150 D4 "registry overlay slot (ADR-0005)"] Whether a stored row
* of `canonicalType` bound to a package registers into that package's overlay
* slot (`SchemaRegistry#registerItem`'s `storedRowPackage`) instead of the
* name's one bare slot: the types the metadata service stores per `(package,
* name)` ({@link PACKAGE_SLOTTED_STORE_TYPES}), so the registry keeps one entry
* per stored row as the store keeps one row per package. With one bare slot,
* two packages' rows of a name (or a package's row beside its pair's, or a
* pair's row beside the package-less row of one member) kept only the row
* registered last: the other package's exact read and its registry item
* answered its shipped item, or nothing, by save or hydration order.
*
* ⛔ Not a `flow`: the name's one bare slot keeps the shipping package's
* declared override over any other row ({@link incomingTakesStoredFlowSlot},
* #28717), which the boot pull reads. ⛔ Not a `permission`: a stored fork of
* a shipped permission set keeps the disposition of its own maintainer ruling
* (see {@link judgesRowHolder}). A package-less row keeps the bare slot.
*/
function takesPackageOverlaySlot(canonicalType: string): boolean {
return PACKAGE_SLOTTED_STORE_TYPES.has(canonicalType) && canonicalType !== 'flow' && canonicalType !== 'permission';
}

/** {@link pairRowEnvelopeHolder}'s answer for a row that is not a customization pair's row of a two-holder name. */
const NOT_A_PAIR_ROW = Symbol('not-a-pair-row');

Expand Down Expand Up @@ -898,13 +921,19 @@ export class ProtocolRegistryHydrationFacet {
if (typeof owner === 'string' && owner !== '') shippers.add(owner);
if (!incomingTakesStoredFlowSlot(held, registered, shippers, installedManifestsOf(registry))) return true;
}
registry.registerItem(type, registered, 'name' as any);
// [#33127, ADR-0150 D4] A row bound to a package takes that package's
// overlay slot ({@link takesPackageOverlaySlot}), so each stored row of
// the name keeps its own entry and each package's exact read serves its
// own, in every save and hydration order.
const rowPackage = boundPackageOf(options.packageId);
const overlaySlot = rowPackage !== undefined && takesPackageOverlaySlot(canonicalType);
registry.registerItem(type, registered, 'name' as any, undefined, overlaySlot ? { storedRowPackage: rowPackage } : undefined);
// [#32820] A package-less row wearing the envelope of the one holder
// served now is recorded, so an enable serving a second holder builds
// it again ({@link healSoleHolderEnvelopes}); any other registration
// of the key forgets it.
// of the key forgets it. [#33127] A package overlay slot is another key.
const member = bundleMemberOf(type, data);
recordSoleHolderEnvelope(registry, envelopeKey(type, rowName, member),
if (!overlaySlot) recordSoleHolderEnvelope(registry, envelopeKey(type, rowName, member),
!options.packageId && typeof envelopePackage === 'string' && envelope && HEALED_OVERLAY_TYPES.has(canonicalType)
? { host, type, canonicalType, name: rowName, member, data, holder: envelopePackage, registered }
: undefined);
Expand Down Expand Up @@ -1754,8 +1783,17 @@ export class ProtocolRegistryHydrationFacet {
registry.removeObjectOverlay(name);
}

let healed = false;
if (typeof registry.removeRuntimeShadow === 'function') {
// [#33127, ADR-0150 D4] A removed row bound to a package sat in that
// package's overlay slot (`SchemaRegistry#removePackageOverlaySlots`,
// the types {@link takesPackageOverlaySlot} names): that slot is
// retired, and tier 1 does not touch the bare slot, which holds the
// package-less row, still stored. The walk then stops where tier 1
// stops, where a packaged artifact of the name is served.
const removedBinding = packageId === HEAL_BINDING_UNKNOWN ? undefined : boundPackageOf(packageId);
const slotRetired = removedBinding !== undefined && typeof registry.removePackageOverlaySlots === 'function'
&& registry.removePackageOverlaySlots(singular, name, memberKey, (id: string) => id === removedBinding).length > 0;
let healed = slotRetired && host.isArtifactBacked(singular, name);
if (!slotRetired && typeof registry.removeRuntimeShadow === 'function') {
healed = registry.removeRuntimeShadow(singular, name, memberKey);
if (type !== singular) {
healed = registry.removeRuntimeShadow(type, name, memberKey) || healed;
Expand Down Expand Up @@ -1815,6 +1853,13 @@ export class ProtocolRegistryHydrationFacet {
const remainingRows = canonical === 'object' && host.isUnservedSealedOverlay(canonical, name)
? []
: storedRows ?? [];
// [#33127] A caller that does not know the removed row's binding (a
// peer on a signal naming no package): every package overlay slot
// whose package no remaining row is bound to is the removed row's.
if (packageId === HEAL_BINDING_UNKNOWN && storedRows != null && typeof registry.removePackageOverlaySlots === 'function') {
const bindings = new Set(remainingRows.map((row) => boundPackageOf(row.package_id)));
registry.removePackageOverlaySlots(singular, name, memberKey, (id: string) => !bindings.has(id));
}
if (remainingRows.length > 0) {
if (canonical === 'object' && !host.isArtifactBacked(canonical, name)) {
const bindings = new Set(remainingRows.map((row) => boundPackageOf(row.package_id) ?? 'sys_metadata'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,17 @@ const SITES: ReadonlyArray<Site & {
// [#23233] The body carries the row's package binding (`withRowBinding`); the disposition is unchanged.
// [#28412] The body is built one line above (`registered`) so a flow row's `package_id` column is
// recorded on it (`bindStoredRow`) before it registers; the body and the disposition are unchanged.
file: 'protocol-registry-hydration.ts', member: 'hydrateOverlayIntoRegistry', args: 'type, registered, \'name\' as any',
// [#33127] A row bound to a package, of a type the registry reads by package, registers in that
// package's own overlay slot (`storedRowPackage`), never under the bare name: the fifth argument.
file: 'protocol-registry-hydration.ts', member: 'hydrateOverlayIntoRegistry',
args: 'type, registered, \'name\' as any, undefined, overlaySlot ? { storedRowPackage: rowPackage } : undefined',
disposition: 'yes, view only: skips a name another package ships',
// [#23298] The write-through runs wherever the registry takes the kernel's stored rows.
path: 'the list\'s hydration on an unscoped kernel; the write-through on an unscoped kernel and on an environment kernel that hydrated its own store; 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)',
+ 'reads answer it from its row; a row bound to a package, of a type the registry reads by package, lands in '
+ 'that package\'s own overlay slot, never the bare name (#33127); a package-less row, and every other type, '
+ 'register as before (#4624)',
viewOnlyBy: 'const bound = canonicalType === \'view\' ? boundPackageOf(options.packageId) : undefined;',
},
{
Expand Down
Loading
Loading