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
12 changes: 12 additions & 0 deletions .changeset/28238-packageless-read-base-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): a same-named row in a dependent package no longer replaces the base package's item for a read that names no package

Clause-②: no
Metadata-protocol: none

When a writable package that depends on a code package saved an item under a name the code package ships (`showcase_task.default` saved into `com.default.acme-custom`), `GET /api/v1/meta/view/showcase_task.default` with no `package` served the dependent package's copy, under the code package's `_packageId`, and so did the layered read's `effective`. The code package that ships the name was not a candidate of that read: the dependent package's row won by being the only stored row.

The by-name read and the layered read that name no package now take their stored content from the code package that ships the name: its own stored row, else the package-unbound row (the overlay Setup writes, which still wins over the code item), else the code item. A read naming the dependent package still serves its own row, and a writable package's own item, a name no code package ships, is still served to the read naming no package. Where a package-less save, delete and the read's `version` land does not move.
51 changes: 51 additions & 0 deletions packages/metadata-protocol/src/protocol-environment-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
* - `findServedOverlayRow` — the stored row a by-name read serves;
* - `packagelessAddress` — the package a write that names none addresses.
*
* [#28238] Beside them, `packagelessContentPackage`: the package a by-name
* read that names none takes its stored content from, given that address and
* the code packages that ship the name. Not a moved body: the rule the two
* by-name reads share.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited.
Expand Down Expand Up @@ -159,3 +164,49 @@ export async function packagelessAddress(
const candidates = [...bindings].sort();
return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates, unbound };
}

/**
* [#28238, ADR-0048 §3.3] The package a by-name read that names no package
* takes its stored CONTENT from: {@link findServedOverlayRow}'s `packageId`
* for {@link ObjectStackProtocolImplementation.getMetaItem} and
* {@link ObjectStackProtocolImplementation.getMetaItemLayered}, so the plain
* read and the layered read's `effective` serve one row.
*
* `address` is the item's package-less write address
* ({@link packagelessAddress}, at `active`); `shipped` is every artifact an
* installed code package registered under the name
* (`ObjectStackProtocolImplementation#shippedArtifactsOf`). The code packages
* that ship the name are candidates of the address beside the stored rows'
* bindings:
*
* - no code package ships the name, or the one package the stored rows are
* bound to ships it: the write address's package, as before (#23291) —
* else `undefined`, any row, the best-effort first match;
* - exactly one code package ships it: that package. Its own stored row,
* else the package-unbound row (ADR-0005), else its code item. A row bound
* to another package, the only stored row of a dependent package that saved
* the same name, is never served for it: that row stood in for the base's
* item on every read naming no package and in the base's own app;
* - several ship it, and no stored row is bound to one of them: `null`, the
* package-unbound row alone, else the code item the registry answers;
* several ship it and a row is bound to one: `undefined`, as before.
*
* Content only, and the read's alone. The write address does not move: a
* save, a delete and the read's `version` still resolve
* {@link packagelessAddress}'s `packageId`.
*/
export function packagelessContentPackage(
address: { readonly packageId: string | null; readonly candidates: readonly string[] },
shipped: readonly unknown[],
): string | null | undefined {
const shippers = new Set<string>();
for (const artifact of shipped) {
const id = (artifact as { _packageId?: unknown } | null | undefined)?._packageId;
if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') shippers.add(id);
}
if (shippers.size === 0 || (address.packageId !== null && shippers.has(address.packageId))) {
return address.packageId ?? undefined;
}
if (shippers.size === 1) return [...shippers][0];
return address.candidates.some((id) => shippers.has(id)) ? undefined : null;
}
9 changes: 6 additions & 3 deletions packages/metadata-protocol/src/protocol-lock-version-gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import type { ObjectStackProtocolImplementation, MetadataAuditEntry } from './protocol.js';
import { canonicalMetaType } from './protocol.js';
import { SysMetadataRepository, unboundItemDraftAddress } from './sys-metadata-repository.js';
import { packagelessContentPackage } from './protocol-environment-rows.js';
import { resolveItemLockLazily } from './item-lock.js';
import { ConflictError } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
Expand Down Expand Up @@ -111,6 +112,7 @@ export interface LockVersionGateHost {
receiptVersion: ObjectStackProtocolImplementation['receiptVersion'];
recordMetadataAudit: ObjectStackProtocolImplementation['recordMetadataAudit'];
rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned'];
shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf'];
storedHashDigest: ObjectStackProtocolImplementation['storedHashDigest'];
storedHeadAt: ObjectStackProtocolImplementation['storedHeadAt'];
}
Expand Down Expand Up @@ -437,9 +439,10 @@ export async function readVersionToken(

/** The body of `ObjectStackProtocolImplementation#packagelessReadAddress`; the method's docblock there is its contract. */
export async function packagelessReadAddress(
this: Pick<LockVersionGateHost, 'packagelessAddress'>,
type: string, name: string, member?: string): Promise<string | undefined> {
return (await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member)).packageId ?? undefined;
this: Pick<LockVersionGateHost, 'packagelessAddress' | 'shippedArtifactsOf'>,
type: string, name: string, member?: string): Promise<string | null | undefined> {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
return packagelessContentPackage(await this.packagelessAddress(singular, name, member), this.shippedArtifactsOf(singular, name));
}

/** The body of `ObjectStackProtocolImplementation#strictDraftReadAddress`; the method's docblock there is its contract. */
Expand Down
31 changes: 22 additions & 9 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js';
// [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it.
import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js';
// [#25467 cut 35] The environment's own `sys_metadata` rows: the family read, the one-row read, the served row and the package-less address.
import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress } from './protocol-environment-rows.js';
import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage } from './protocol-environment-rows.js';
// [#25467 cut 35] The stored rows of a collection, folded over the registered ones.
import { foldStoredCollection } from './protocol-stored-collection-fold.js';
// [#25467 cut 35] The environment-wide package uninstall verb.
Expand Down Expand Up @@ -10844,12 +10844,18 @@ export class ObjectStackProtocolImplementation implements
// The package-less binding ({@link packagelessReadAddress}'s), selected
// from those rows; the `version` below ({@link readVersionToken})
// takes the same binding instead of reading it again.
const packagelessBinding = readState === 'active' && !request.packageId
? (await this.packagelessAddress(
const packagelessAddressed = readState === 'active' && !request.packageId
? await this.packagelessAddress(
PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member, 'active', activeRows,
)).packageId
)
: undefined;
const contentPackageId = request.packageId || (packagelessBinding ?? undefined);
const packagelessBinding = packagelessAddressed?.packageId;
// [#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
? packagelessContentPackage(packagelessAddressed, this.shippedArtifactsOf(request.type, request.name))
: 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 All @@ -10868,7 +10874,7 @@ export class ObjectStackProtocolImplementation implements
state: 'draft',
...(draftContentAddress
? { packageId: draftContentAddress.packageId }
: contentPackageId ? { packageId: contentPackageId } : {}),
: contentPackageId !== undefined ? { packageId: contentPackageId } : {}),
...memberOption(member),
}))?.row;
// [#23715] Two or more packages and no package-unbound draft:
Expand Down Expand Up @@ -10928,7 +10934,7 @@ export class ObjectStackProtocolImplementation implements
state: readState,
...(readState === 'draft' && draftContentAddress
? { packageId: draftContentAddress.packageId }
: contentPackageId ? { packageId: contentPackageId } : {}),
: contentPackageId !== undefined ? { packageId: contentPackageId } : {}),
...memberOption(member),
}, activeRows))?.row;
// [#23715] Two or more packages and no package-unbound draft: the
Expand Down Expand Up @@ -11559,7 +11565,7 @@ export class ObjectStackProtocolImplementation implements
type: request.type,
name: request.name,
state: 'active',
...(contentPackageId ? { packageId: contentPackageId } : {}),
...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}),
...memberOption(member),
});
overlayLockLayer = await this.overlayLockLayerAt({
Expand Down Expand Up @@ -17077,11 +17083,18 @@ export class ObjectStackProtocolImplementation implements
* layered read's `effective` and `/published` serve one row. [#23715] The
* strict draft read asks {@link strictDraftReadAddress} instead: the draft
* its `version` names.
*
* [#28238] The code packages that ship the name are candidates too
* ({@link packagelessContentPackage}, the rule {@link getMetaItem} applies
* to the rows it already read): a row bound to another package is not
* served for a name one code package ships, and `null` is the
* package-unbound row alone. The write address does not move.
*/
private packagelessReadAddress(type: string, name: string, member?: string): Promise<string | undefined> {
private packagelessReadAddress(type: string, name: string, member?: string): Promise<string | null | undefined> {
// [#25467 cut 5] The body moved to `protocol-lock-version-gates.ts` (see `LockVersionGateHost`).
const host: ThisParameterType<typeof packagelessReadAddress> = {
packagelessAddress: (...a) => this.packagelessAddress(...a),
shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a),
};
return packagelessReadAddress.call(host, type, name, member);
}
Expand Down
Loading
Loading