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
15 changes: 15 additions & 0 deletions .changeset/31656-customizations-list-moved-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): the customizations read lists a moved edit under the package that serves it

Clause-②: no — what the customizations list holds for a moved edit; no accept set changes
Metadata-protocol: none

When a later version of an App moves an item from one of its packages to another (an ADR-0130 D4 suite split, or its rollback), the tenant's edit of the item is served at the package that ships it now. `GET /packages/:id/customizations` (`listServedOverrides`) now lists that edit there too, and only there.

- **Before.** The list took a pair override's package from its declaration, the owner at the time of the edit. After a flat → composed split the edit was listed under the App, which no longer ships the item, and not under the module that serves it; after a rollback it was listed under neither.
- **Now.** The list picks each item's row through the served reads' own candidates and predicates (`servedOverlayRowCandidates`, `holdsServedOverlayCandidate`, `isMovedOverrideRowOf`), so a package lists exactly the pair override its address serves. Same App is read only from the install record (`appOf` over the installed manifests), never from an id prefix; a row of another App's pair, or a declaration naming another App, is never listed.
- **Unchanged.** An edit whose owner still ships the item is listed as before. The list stays the pair's rows: a row bound to a package of the App itself (saved before ADR-0146 D11) is listed under no package, before or after a move. The upgrade plan's override advice reads this list, so it follows the same rule. The item lock already reads every stored row of the item whichever package it is bound to, so it answers for a moved edit what it answers for an edit saved at the new owner; that is pinned, not changed.
- **Nothing is written.** The read moves and rewrites no row.
104 changes: 81 additions & 23 deletions packages/metadata-protocol/src/protocol-served-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
*
* ## One predicate, not a second spelling
*
* A row is listed exactly when {@link isServedOverrideRowOf} holds for it and
* X — the predicate the effective read asks before it serves a `.custom` row
* at X's address (`servedOverlayRowCandidates`, N-10). ⛔ Never a body-only
* A row is listed exactly when it holds one of the pair's candidates for X in
* the served reads' order — `isServedOverrideRowOf`, the predicate the
* effective read asks before it serves a `.custom` row at X's address
* (`servedOverlayRowCandidates`, N-10), or [#31656] the moved edit's
* {@link isMovedOverrideRowOf} (below). ⛔ Never a body-only
* test such as `overrides.package === X`: a declaration from a package that is
* not X's pair, or a pair row with no declaration, is not served as X's item,
* so it is not an override of X either. The agreement is pinned in
Expand All @@ -40,6 +42,27 @@
*
* ## What it reports, and what it never computes
*
* ## [#31656] A moved edit is listed where it is served now, and only there
*
* A row is admitted through the served reads' own candidates, in their order
* ({@link servedOverlayRowCandidates}, {@link holdsServedOverlayCandidate}):
* the pair's declared override of X first, then the pair's override of the
* item from before a later version of X's App moved it to X
* ({@link isMovedOverrideRowOf}, `suite-moved-overlay.ts`). So after a suite
* split (or its rollback) the tenant's edit is listed under the package that
* ships the item now, the one whose address serves it. ⛔ It is no longer
* listed under the former owner whose name the declaration still carries: a
* pair row that a package shipping the item now holds as a moved edit is that
* package's. Same App is read only through the predicates (`appOf` over the
* installed manifests), never here. Which packages ship the item is the
* registry's answer the served by-name read takes too (`shippedArtifactsOf`).
*
* The list stays the pair's rows. A row bound to a package of the App itself
* (a Regime O save from before the ADR-0146 D11 close) is served at the new
* owner through `isMovedBaseRowOf`'s candidate, last, but it is not a
* pair row, so it is listed under no package, as such a row bound to the
* package that still ships the item is not listed today.
*
* Each entry is the row's own facts: its type (folded to the canonical
* singular), its name, its bundle member when the type's identity is a pair,
* the `basedOn` its declaration records and the row's history `version`.
Expand All @@ -55,14 +78,17 @@ import { pairsOf } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { storedRowDocument } from './item-lock.js';
import type { ObjectStackProtocolImplementation } from './protocol.js';
import { isServedOverrideRowOf } from './served-override.js';
import { holdsServedOverlayCandidate, servedOverlayRowCandidates } from './protocol-overlay-merge.js';
import { shippingPackageIds } from './protocol-environment-rows.js';
import { isMovedOverrideRowOf } from './suite-moved-overlay.js';
import { installedManifestsOfEngine } from './installed-manifests.js';

/** The class members {@link listServedOverrides} reads, typed as the class declares them. */
export interface ServedOverridesHost {
readonly engine: ObjectStackProtocolImplementation['engine'];
readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows'];
readonly declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow'];
readonly shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf'];
readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned'];
}

Expand All @@ -81,8 +107,10 @@ export interface ServedOverrideEntry {
}

/**
* The served overrides of `request.packageId`'s items: the ACTIVE rows of its
* pair that {@link isServedOverrideRowOf} admits and no read declines, ordered
* The served overrides of `request.packageId`'s items: per item, the ACTIVE
* row of its pair that the served reads' candidates pick (the declared
* override, else [#31656] the moved edit, module header) and no read
* declines, ordered
* by type, name and member. A base with no pair package, or a package id that
* has no pair (a `.custom` id, an invalid id), answers `[]`; this read never
* creates the pair.
Expand All @@ -93,7 +121,7 @@ export interface ServedOverrideEntry {
* An unprovisioned table answers `[]`.
*/
export async function listServedOverrides(
this: Pick<ServedOverridesHost, 'engine' | 'findEnvironmentRows' | 'declinesStoredRow' | 'rethrowUnlessMetadataStoreUnprovisioned'>,
this: Pick<ServedOverridesHost, 'engine' | 'findEnvironmentRows' | 'declinesStoredRow' | 'shippedArtifactsOf' | 'rethrowUnlessMetadataStoreUnprovisioned'>,
request: { packageId: string },
): Promise<ServedOverrideEntry[]> {
const base = request.packageId;
Expand All @@ -114,31 +142,61 @@ export async function listServedOverrides(
// overridden".
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
const entries = new Map<string, ServedOverrideEntry>();
// [#31656] The pair's rows of one item (type folded, name, member) are
// judged together: the served reads pick one of them by candidate order.
const items = new Map<string, { type: string; name: string; member: string | undefined; rows: any[] }>();
for (const row of rows ?? []) {
if (typeof row?.name !== 'string' || row.name === '') continue;
if (!isServedOverrideRowOf(row, base, installed)) continue;
const type = PLURAL_TO_SINGULAR[row.type] ?? row.type;
if (this.declinesStoredRow(type, row.name, row)) continue;
if (typeof type !== 'string' || type === '') continue;
const member = typeof row.bundle_member === 'string' && row.bundle_member !== '' ? row.bundle_member : undefined;
// `isServedOverrideRowOf` parsed this body already; a body it admitted parses.
const declaration = (storedRowDocument(row) as { overrides?: { basedOn?: unknown } }).overrides;
const version = typeof row.version === 'number' && Number.isInteger(row.version) && row.version >= 0
? row.version
const key = `${type}\u0000${row.name}\u0000${member ?? ''}`;
const item = items.get(key) ?? { type, name: row.name as string, member, rows: [] as any[] };
item.rows.push(row);
items.set(key, item);
}
const entries: ServedOverrideEntry[] = [];
for (const { type, name, member, rows: itemRows } of items.values()) {
// The code packages that ship the item, read once per item and only
// when a moved edit is in question (the by-name read's own reading).
let shippers: ReadonlySet<string> | undefined;
const shippersOf = (): ReadonlySet<string> =>
(shippers ??= shippingPackageIds(this.shippedArtifactsOf(type, name, undefined, member)));
const ships = (packageId: string): boolean => shippersOf().has(packageId);
let served: any;
for (const candidate of servedOverlayRowCandidates({ packageId: base, servedOverride: true, installed, ships })) {
// Only the pair's candidates: the base's own row, the
// package-less row and a row bound to a former owner are not
// the pair's, and this list reads the pair's rows alone.
if (candidate.overrideOf === undefined || candidate.moved === 'base') continue;
served = itemRows.find((row) => (row.type === type ? 'canonical' : 'other') === candidate.spelling
&& row.package_id === candidate.packageId
&& holdsServedOverlayCandidate(candidate, row, installed, ships));
if (served === undefined) continue;
// A declared override the base no longer ships, which a package
// shipping the item now holds as its moved edit, is that
// package's: listed there, never also here.
if (candidate.moved === undefined && !ships(base)
&& [...shippersOf()].some((now) => now !== base && isMovedOverrideRowOf(served, now, installed, ships))) {
served = undefined;
}
break;
}
if (served === undefined) continue;
if (this.declinesStoredRow(type, name, served)) continue;
// The candidate's predicate parsed this body already; a body it admitted parses.
const declaration = (storedRowDocument(served) as { overrides?: { basedOn?: unknown } }).overrides;
const version = typeof served.version === 'number' && Number.isInteger(served.version) && served.version >= 0
? served.version
: undefined;
const entry: ServedOverrideEntry = {
entries.push({
type,
name: row.name,
name,
...(member !== undefined ? { member } : {}),
...(typeof declaration?.basedOn === 'string' ? { basedOn: declaration.basedOn } : {}),
...(version !== undefined ? { version } : {}),
};
// A row stored under the type's other spelling (pre-#4432 residue)
// and one under the canonical spelling are one item: listed once,
// the canonical row's facts kept.
const key = `${type}\u0000${row.name}\u0000${member ?? ''}`;
if (!entries.has(key) || row.type === type) entries.set(key, entry);
});
}
return [...entries.values()].sort((a, b) =>
return entries.sort((a, b) =>
a.type.localeCompare(b.type) || a.name.localeCompare(b.name) || (a.member ?? '').localeCompare(b.member ?? ''));
}
1 change: 1 addition & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3443,6 +3443,7 @@ export class ObjectStackProtocolImplementation implements
get engine() { return readEngine(); },
findEnvironmentRows: (...a) => this.findEnvironmentRows(...a),
declinesStoredRow: (...a) => this.declinesStoredRow(...a),
shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a),
rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a),
};
return listServedOverrides.call(host, request);
Expand Down
93 changes: 93 additions & 0 deletions packages/runtime/src/domains/meta-overlay-follows-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,96 @@ describe('[#31593] a tenant\'s edit follows its view across the packages of one
expect((r?.item ?? r)?.label).toBe('Case');
}, 60_000);
});

/** The views `packageId`'s customizations read lists as overridden, by name and recorded `basedOn`. */
async function listedOverrides(s: Session, packageId: string) {
const entries: Array<{ type: string; name: string; basedOn?: string }> = await s.protocol.listServedOverrides({ packageId });
return entries.filter((e) => e.type === 'view').map((e) => [e.name, e.basedOn ?? null]);
}
/** What the item lock answers for the view at `packageId`'s address: the read's envelope, and the save door. */
async function lockAt(s: Session, packageId: string) {
const r: any = await s.protocol.getMetaItem({ type: 'view', name: VIEW, packageId });
const save = await s.protocol.saveMetaItem({
type: 'view', name: VIEW, packageId: PAIR, item: { ...(await editedView(s, packageId)), overrides: { package: packageId, basedOn: '6.2.0' } },
}).then(() => 'saved', (e: any) => e?.code ?? String(e));
return { lock: r?.lock, editable: r?.editable, deletable: r?.deletable, lockReason: r?.lockReason, save };
}

describe('[#31656] the customizations read lists a moved edit under the package that serves it, and the item lock answers for it as for an edit there', () => {
it('flat → composed: the module lists the edit saved under the App, and the App no longer does', async () => {
const db = tenantDb();
const flat = await boot(db, 'flat');
await editInPair(flat, APP, '6.1.0');
// CONTROL: listed under its own package, as before the move.
expect(await listedOverrides(flat, APP)).toEqual([[VIEW, '6.1.0']]);

const composed = await boot(db, 'composed');
expect(await composed.label(MODULE)).toBe(EDITED);
expect({ module: await listedOverrides(composed, MODULE), app: await listedOverrides(composed, APP) })
.toEqual({ module: [[VIEW, '6.1.0']], app: [] });
}, 60_000);

it('composed → flat (rollback): the App lists the edit saved under the module, though the module is gone', async () => {
const db = tenantDb();
const composed = await boot(db, 'composed');
await editInPair(composed, MODULE, '6.2.0');
expect(await listedOverrides(composed, MODULE)).toEqual([[VIEW, '6.2.0']]);

const flat = await boot(db, 'flat');
expect(await flat.label(APP)).toBe(EDITED);
expect({ app: await listedOverrides(flat, APP), module: await listedOverrides(flat, MODULE) })
.toEqual({ app: [[VIEW, '6.2.0']], module: [] });
}, 60_000);

it('CONTROL: a former owner that still ships the view lists its edit as today; the module lists nothing', async () => {
const db = tenantDb();
const flat = await boot(db, 'flat');
await editInPair(flat, APP, '6.1.0');
const both = await boot(db, 'both');
expect({ app: await listedOverrides(both, APP), module: await listedOverrides(both, MODULE) })
.toEqual({ app: [[VIEW, '6.1.0']], module: [] });
}, 60_000);

it('NEGATIVE: a row of another App\'s pair, or a declaration naming another App, is never listed under the module', async () => {
const db = tenantDb();
const flat = await boot(db, 'flat', { partner: true });
const body = await editedView(flat, APP);
const composed = await boot(db, 'composed', { partner: true });
expect(((await composed.protocol.ensureCustomizationPackage({ basePackageId: PARTNER })) as any).packageId).toBe(`${PARTNER}.custom`);
await composed.layRow('view', VIEW, `${PARTNER}.custom`, { ...body, overrides: { package: APP, basedOn: '6.1.0' } });
expect(await listedOverrides(composed, MODULE)).toEqual([]);
await composed.layRow('view', VIEW, PAIR, { ...body, overrides: { package: PARTNER, basedOn: '2.0.0' } });
expect({ module: await listedOverrides(composed, MODULE), app: await listedOverrides(composed, APP), partner: await listedOverrides(composed, PARTNER) })
.toEqual({ module: [], app: [], partner: [] });
}, 60_000);

it('CONTROL: a pre-close row bound to the App is served at the module and listed under neither, as such a row bound to its owner is not listed today', async () => {
const db = tenantDb();
const flat = await boot(db, 'flat');
await flat.layRow('view', VIEW, APP, await editedView(flat, APP));
expect(await listedOverrides(flat, APP)).toEqual([]);
const composed = await boot(db, 'composed');
expect(await composed.label(MODULE)).toBe(EDITED);
expect({ module: await listedOverrides(composed, MODULE), app: await listedOverrides(composed, APP) }).toEqual({ module: [], app: [] });
}, 60_000);

it('the item lock answers for a moved edit at the module exactly what it answers for an edit saved at the module', async () => {
// The lock a stamped row carries (a loader's or an install's stamp;
// no save door writes one), on the module's own edit and on a moved one.
const stamped = { _lock: 'no-overlay', _lockReason: 'Stamped by the install.' };
const ownDb = tenantDb();
const own = await boot(ownDb, 'composed');
await own.layRow('view', VIEW, PAIR, { ...(await editedView(own, MODULE)), overrides: { package: MODULE, basedOn: '6.2.0' }, ...stamped });
const atOwner = await lockAt(own, MODULE);

const movedDb = tenantDb();
const flat = await boot(movedDb, 'flat');
await flat.layRow('view', VIEW, PAIR, { ...(await editedView(flat, APP)), overrides: { package: APP, basedOn: '6.1.0' }, ...stamped });
const composed = await boot(movedDb, 'composed');
expect(await composed.label(MODULE)).toBe(EDITED);
const moved = await lockAt(composed, MODULE);

expect(atOwner).toEqual({ lock: 'no-overlay', editable: false, deletable: true, lockReason: 'Stamped by the install.', save: 'ITEM_LOCKED' });
expect(moved).toEqual(atOwner);
}, 60_000);
});
Loading