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/21510-list-read-stored-row-wins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): the object door lists a stored view row under its own name even where a stored view container expands that name, as the by-name read already answers

Clause-②: no

- **What changed.** `GET /api/v1/meta/view?object=…` (the object door) no longer lets a stored view container's expansion replace a stored row of the same name. A view item (a row carrying `viewKind`) saved under a name the container also expands, such as `<object>.default` beside a stored overlay of that object's container, is now what the object door lists under that name. Before, the object door listed the container's expansion there while the by-name read (`GET /api/v1/meta/view/NAME`) answered the stored row. Both doors now answer the row.
- **The rule.** A row stored under exactly a name is the override for that name (ADR-0005 keys an overlay by its own name). An expansion fills only the names that have no row of their own. The list read and the by-name read decide this with one test, over the rows each selects for the same caller, so a row stored for one organization does not hide the expansion from any other caller.
- **A container stored under one of its own expanded names.** That row is the name's own row as well, so its expansion no longer fills the name. The object door never lists a container, so it now lists nothing under that name. Before, it listed the container's expansion there. The by-name read answers the stored container, as before.
- **What does not change.** Every name a container expands that has no stored row of its own is still listed, and on both doors it still replaces a packaged view of the same name. The by-name read answers as before. The save door is unchanged. No response shape gains or loses a key.
40 changes: 38 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8352,12 +8352,23 @@ export class ObjectStackProtocolImplementation implements
// another package's object every name a container expands
// derives from the container's own name, never one of that
// package's `<object>.<key>` names.
//
// [#21510] …and it never replaces a STORED ROW of that name.
// A row stored under exactly the name is the sanctioned
// override for it (ADR-0005 keys an overlay by its own name);
// an expansion fills only a name with no row of its own. The
// test is {@link namesWithOwnStoredRow} over this caller's
// `records`, the one the by-name read asks, so the two doors
// answer the same row for the name. An item the registry or a
// package supplies under the name is still replaced, as before.
if (isView) {
const byName = new Map<string, unknown>();
for (const it of items as any[]) {
if (it && typeof it === 'object' && typeof it.name === 'string') byName.set(it.name, it);
}
const ownRowNames = this.namesWithOwnStoredRow(records);
for (const { item: vi } of this.expandStoredViewContainers(request.type, overlays)) {
if (ownRowNames.has(vi.name as string)) continue;
byName.set(vi.name as string, vi);
}
items = Array.from(byName.values());
Expand Down Expand Up @@ -8818,6 +8829,30 @@ export class ObjectStackProtocolImplementation implements
return out;
}

/**
* [#21510] The names that have a stored row of their own among `records`,
* the active rows {@link readActiveOverlayRows} selected for one caller.
*
* ADR-0005 keys an overlay by its own name, so a row stored under exactly a
* name is the sanctioned override for that name. An expansion is derived
* from its container, so it fills only a name that is NOT in this set. This
* is the one predicate both doors ask: the list read
* ({@link readFlattenedMetaItems}) never lets an expansion displace a
* stored row of the same name, and the by-name read
* ({@link resolveRowlessExpandedView}) answers an expansion only for a name
* outside it. Both doors pass the rows they selected for the same caller,
* so a name that has a row in one organization only is row-less for every
* other caller. ⛔ Never a second test of "this name has its own row":
* two tests are two rules, and the doors would disagree again.
*/
private namesWithOwnStoredRow(records: readonly any[]): ReadonlySet<string> {
const names = new Set<string>();
for (const record of records) {
if (typeof record?.name === 'string') names.add(record.name);
}
return names;
}

/**
* [#21442] The item the list read serves under `request.name` when that
* name is ROW-LESS — no stored row of its own — and a stored view
Expand Down Expand Up @@ -8845,7 +8880,8 @@ export class ObjectStackProtocolImplementation implements
* ⛔ No kernel-specific branch — every kernel answers through this path.
*
* A stored row of this very name is the name's own row and is answered
* as such by the caller's own read, never an expansion. The `container`
* as such by the caller's own read, never an expansion — the same
* predicate the list read applies ({@link namesWithOwnStoredRow}). The `container`
* returned is the stored row the item derives from — its own name, body,
* package and organization — which the layered read reports as the
* name's provenance and the history and diff reads resolve to.
Expand All @@ -8867,7 +8903,7 @@ export class ObjectStackProtocolImplementation implements
// this name".
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
if (records.some((record) => record?.name === request.name)) return undefined;
if (this.namesWithOwnStoredRow(records).has(request.name)) return undefined;
let found: RowlessExpandedView | undefined;
for (const expanded of this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, records))) {
if (expanded.item.name === request.name) found = expanded;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,128 @@ describe('#21334 a container on another package\'s object never takes that packa
}
});
});

/**
* #21510 — a stored row named exactly like a name a stored container
* expands answers that name on BOTH doors.
*
* Triage's ruling: the stored row wins on both doors. ADR-0005 overlays are
* name-keyed, so a row stored under exactly that name is the sanctioned
* override for it; an expansion is derived from its container, so it fills
* only names that have no row of their own. That is the rule #21442 gave
* the by-name read, and the object door's list read adopts it: ⛔ not a
* second rule, both doors ask one predicate over the caller's own row
* selection.
*
* Measured on `origin/main` before this change, with this harness: the
* object door listed the container's expansion under the row's name
* (`FromContainer`) while the by-name read answered the stored row
* (`ByNameRow`), on both kernels, in both scopes and in either write
* order. The name the same container expands with no row of its own still
* answers the expansion on both doors — the control.
*/
describe('#21510 a stored row named exactly like an expansion answers that name on both doors', () => {
const withoutDiagnostics = (item: any) => {
if (!item || typeof item !== 'object') return item;
const { _diagnostics: _drop, ...rest } = item;
return rest;
};
/** The dev's setup: a stored overlay of the showcase's own `showcase_task` container… */
const container = {
name: TASK,
list: { label: 'FromContainer', type: 'grid', data, columns: [{ field: 'title' }] },
listViews: {
in_progress: { label: 'FromContainer In Progress', type: 'grid', data, columns: [{ field: 'title' }] },
},
};
/** …plus a stored row named exactly like the name its bare `list` expands to. */
const row = {
name: DEFAULT, object: TASK, viewKind: 'list', label: 'ByNameRow',
config: { type: 'grid', data, columns: [{ field: 'title' }, { field: 'status' }] },
};
/** The control: a name the container expands that has no row of its own. */
const ROWLESS = `${TASK}.in_progress`;
const saveView = (protocol: Protocol, name: string, item: unknown, organizationId?: string) =>
protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId) } as any);
/** The two doors answer `name` with one item, and that item is the one `expectItem` names. */
const expectBothDoors = async (
protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void,
) => {
const listed = named(await objectDoor(protocol, organizationId), name);
expect(listed, `exactly one item answers ${name} on the object door`).toHaveLength(1);
expectItem(listed[0]);
const read = await byNameDoor(protocol, name, organizationId);
expectItem(read);
expect(withoutDiagnostics(read), `${name}: the by-name read answers the item the object door lists`)
.toEqual(withoutDiagnostics(listed[0]));
};
const expectTheRow = (v: any) => {
expect(v?.label).toBe('ByNameRow');
expect(v?.config).toEqual(row.config);
};
const expectTheExpansion = (v: any) => {
expect(v?.label).toBe('FromContainer In Progress');
expect(v?.config?.columns).toEqual([{ field: 'title' }]);
};

for (const [kernel, environmentId] of KERNELS) {
describe(`on ${kernel}`, () => {
for (const organizationId of [undefined, ORG]) {
const scope = organizationId ? 'organization-scoped' : 'environment-wide';
for (const order of ['the container first', 'the row first'] as const) {
it(`${scope}, ${order}: the stored row answers its name on both doors; the row-less expanded name answers the expansion`, async () => {
const { protocol, rows } = showcaseHarness(environmentId);
const writes = [
() => saveView(protocol, TASK, container, organizationId),
// The public input: the save door accepts a write by the expanded name.
() => saveView(protocol, DEFAULT, row, organizationId),
];
for (const write of order === 'the container first' ? writes : [...writes].reverse()) await write();
expect(
[...rows.values()].filter((r) => r.name === DEFAULT && r.organization_id === (organizationId ?? null)),
'the save door stored the row under the expanded name',
).toHaveLength(1);

await expectBothDoors(protocol, DEFAULT, organizationId, expectTheRow);
// The by-name family asks the same predicate: the
// row's name keeps its own change log and its own
// diff, never the container's.
const events = (await protocol.historyMetaItem({ type: 'view', name: DEFAULT, ...scoped(organizationId) })).events;
expect(events.length, 'the row has a change log of its own').toBeGreaterThan(0);
expect(events.every((e: any) => e.ref.name === DEFAULT), 'every event names the row').toBe(true);
expect((await (protocol as any).diffMetaItem({ type: 'view', name: DEFAULT, ...scoped(organizationId) })).name)
.toBe(DEFAULT);
// CONTROL — a row-less name the same container expands.
await expectBothDoors(protocol, ROWLESS, organizationId, expectTheExpansion);
// The contract the ruling keeps (#21334): every name
// the object door lists answers that same item by name.
for (const listed of await objectDoor(protocol, organizationId)) {
const read = await byNameDoor(protocol, listed.name, organizationId);
expect(withoutDiagnostics(read), `${listed.name} by name`).toEqual(withoutDiagnostics(listed));
}
});
}
}

it('the predicate is read over the caller\'s own rows: an organization\'s row wins for that organization only', async () => {
const { protocol } = showcaseHarness(environmentId);
await saveView(protocol, TASK, container);
await saveView(protocol, DEFAULT, row, ORG);

// The organization that holds the row: the row, on both doors.
await expectBothDoors(protocol, DEFAULT, ORG, expectTheRow);
// A caller for whom the name has no row of its own: the
// container's expansion, on both doors.
const expectTheDefaultExpansion = (v: any) => {
expect(v?.label).toBe('FromContainer');
expect(v?.config?.columns).toEqual([{ field: 'title' }]);
};
await expectBothDoors(protocol, DEFAULT, undefined, expectTheDefaultExpansion);
await expectBothDoors(protocol, DEFAULT, 'org_globex', expectTheDefaultExpansion);
});
});
}
});
});

/**
Expand Down
Loading