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/32714-registry-action-door-loose-ends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/metadata': patch
---

fix(metadata): the in-memory store declares a view where it is made, and a refusal of a slot it does not hold names each holder once

Clause-②: no
Metadata-protocol: none

Loose ends of the per-member registry keys. No served answer changes.

- **Views are declared, not inferred.** A read the registry cannot answer judges a view assembled from the registry's and the loaders' items. A bundled type's slot (`email_template`, one member per `locale`) answers that view's first member, the member its sources wrote last. The store used to infer "this is a view" from "no write was ever recorded on it". It now marks the view where it builds it, so a store that is not a view answers by its write order, as every other read of "written last" does. Every view the manager builds is marked, so each read answers as before.
- **One holder, one candidate.** The refusal of a write addressed to a package that holds no slot of the name (`not-held-by-qualifier`, `409`) lists each holder once. One package's two locales, or one package's action bound to two objects, is one candidate, as the other refusals already count it. Its one caller, `DatabaseLoader`'s rollback, keys one row per slot, so its refusals read the same.

Pinned with them, unchanged: a second locale of a bundled item is announced `added` and a rewrite of a held locale `changed`, because an email template's identity is `(name, locale)`. A package's first item of a name whose bundle the environment holds takes over that member only, and leaves the environment's other locales the environment's, in the registry, the `MemoryLoader` and `DatabaseLoader` alike. On a host whose `protocol` serves no merged action listing, two packages' same-named action bound to one object is refused: the execution door answers `409` naming both packages and runs nothing, and the activation door writes no ledger row. MCP `list_actions` lists one row per package, and the bare `run_action` is refused naming both.
117 changes: 117 additions & 0 deletions packages/metadata/src/metadata-manager-email-template-slot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,120 @@ describe('[#32517] a bundled type keeps one item per (package, name, member) in
}
});
});

/**
* [#32714 item 1] Which watcher event a write of a bundled type's member
* announces. Decided by the governing text, not picked: an email template's
* identity is `(name, locale)` — `EmailTemplateDefinitionSchema.locale` is "the
* second half of the bundle key" and "multiple rows sharing one `name` form an
* i18n bundle" (`packages/spec/src/system/email-template.zod.ts`; the identity
* table `ITEM_KEY_DISCRIMINATORS` in `@objectstack/metadata-core` says the
* same) — and `register()` announces `added` for a first registration and
* `changed` for an overwrite (its own contract, matching the repository
* watch path's `create` / `update` split). A second locale is a new row of the
* bundle and overwrites nothing, so it is `added`; a rewrite of a locale the
* slot already holds is `changed`. `watchService` maps them to `registered` /
* `updated`. No realtime event is published for either: `email_template` has
* no declared `MetadataEventType`.
*/
describe('[#32714 item 1] a second locale of a bundled item is announced `added`, a rewrite of a held locale `changed`', () => {
const member = (packageId: string | undefined, locale: string, subject = `${packageId ?? 'env'}:${locale}`) => ({
name: NAME,
locale,
subject,
bodyHtml: '<p>hi</p>',
...(packageId === undefined ? {} : { _packageId: packageId, _provenance: 'package' }),
});

function watched(type = 'email_template') {
const manager = new MetadataManager({ formats: ['json'], loaders: [] });
const raw: string[] = [];
const service: string[] = [];
const published: unknown[] = [];
manager.subscribe(type, (event) => { raw.push(event.type); });
manager.watchService(type, (event) => { service.push(event.type); });
manager.setRealtimeService({
publish: async (event: unknown) => { published.push(event); },
subscribe: async () => 'sub-1',
unsubscribe: async () => undefined,
} as never);
return { manager, raw, service, published };
}

it('one package: en-US added, fr-FR added (a new member of the bundle), fr-FR again and en-US again changed', async () => {
const { manager, raw, service, published } = watched();
for (const item of [member(ALPHA, 'en-US'), member(ALPHA, 'fr-FR'), member(ALPHA, 'fr-FR', 'fr again'), member(ALPHA, 'en-US', 'en again')]) {
await manager.register('email_template', NAME, item);
}
expect(raw).toEqual(['added', 'added', 'changed', 'changed']);
expect(service).toEqual(['registered', 'registered', 'updated', 'updated']);
expect(published, 'no declared realtime event for the type').toEqual([]);
});

it('the environment\'s bundle: its second locale is added too, and a package taking over its canonical member is changed (it replaces that row)', async () => {
const { manager, raw } = watched();
for (const item of [member(undefined, 'en-US'), member(undefined, 'fr-FR'), member(ALPHA, 'en-US')]) {
await manager.register('email_template', NAME, item);
}
expect(raw).toEqual(['added', 'added', 'changed']);
});

it('CONTROL — an unbundled by-package type keeps one item per slot: its second write is changed, whatever `locale` it carries', async () => {
const { manager, raw } = watched('agent');
await manager.register('agent', NAME, { name: NAME, locale: 'en-US', _packageId: ALPHA });
await manager.register('agent', NAME, { name: NAME, locale: 'fr-FR', _packageId: ALPHA });
expect(raw).toEqual(['added', 'changed']);
});
});

/**
* [#32714 item 2] The environment holds a bundle (`en-US`, `fr-FR`) of a name
* no package holds; then a package ships the name's canonical member. The
* package's write takes over the bare slot's SAME member only (its row, as
* `DatabaseLoader` binds the package-less row of the member it writes): the
* environment's `fr-FR` stays the environment's. Pinned as it stands — the
* registry and the door's `MemoryLoader` agree, and `DatabaseLoader` leaves
* that row package-less too — so no layer moves a member nobody wrote.
*/
describe('[#32714 item 2] a package taking over a bundled name moves only the member it writes', () => {
const member = (packageId: string | undefined, locale: string, subject = `${packageId ?? 'env'}:${locale}`) => ({
name: NAME,
label: 'Welcome',
locale,
subject,
bodyHtml: '<p>hi</p>',
...(packageId === undefined ? {} : { _packageId: packageId, _provenance: 'package' }),
});
const subjects = (items: unknown[]) => (items as Array<{ subject: string }>).map((item) => item.subject).sort();
const TAKEN_OVER = () => [member(undefined, 'en-US'), member(undefined, 'fr-FR'), member(ALPHA, 'en-US')];

async function shapes(): Promise<Array<[string, MetadataManager]>> {
const registry = new MetadataManager({ formats: ['json'], loaders: [] });
for (const item of TAKEN_OVER()) registry.registerInMemory('email_template', NAME, item);
const loader = new MemoryLoader();
for (const item of TAKEN_OVER()) await loader.save('email_template', NAME, item);
return [['registry', registry], ['MemoryLoader', new MetadataManager({ formats: ['json'], loaders: [loader] })]];
}

it('list: the package\'s en-US and the environment\'s fr-FR, each once; reads answer the package\'s slot', async () => {
for (const [label, manager] of await shapes()) {
expect(subjects(await manager.list('email_template')), label).toEqual([`${ALPHA}:en-US`, 'env:fr-FR']);
expect(((await manager.get('email_template', NAME, ALPHA)) as any)?.subject, label).toBe(`${ALPHA}:en-US`);
expect(((await manager.get('email_template', NAME)) as any)?.subject, label).toBe(`${ALPHA}:en-US`);
}
});

it('a removal naming the package leaves the environment\'s fr-FR, which a read naming none then answers', async () => {
for (const [label, manager] of await shapes()) {
await manager.unregister('email_template', NAME, { packageId: ALPHA });
expect(subjects(await manager.list('email_template')), label).toEqual(['env:fr-FR']);
expect(((await manager.get('email_template', NAME)) as any)?.subject, label).toBe('env:fr-FR');
}
});

it('the package\'s own fr-FR then takes the environment\'s over in turn: one fr-FR, the package\'s', async () => {
const registry = new MetadataManager({ formats: ['json'], loaders: [] });
for (const item of [...TAKEN_OVER(), member(ALPHA, 'fr-FR')]) registry.registerInMemory('email_template', NAME, item);
expect(subjects(await registry.list('email_template'))).toEqual([`${ALPHA}:en-US`, `${ALPHA}:fr-FR`]);
});
});
23 changes: 22 additions & 1 deletion packages/metadata/src/metadata-manager-match-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,7 +1117,28 @@ describe('per-package loader storage for every type stored per package (ADR-0150
expect(((await manager.list(type))[0] as any)?.subject).toBe('en again');
});

it.each(UNBUNDLED)('%s: the loaders\' by-name reads still answer the item saved last; the plural reads answer every package\'s item, over the door\'s store [#32517] and over the stored rows (#32521; BEFORE: the database\'s one row per name, the one saved last)', async (type) => {
/**
* [#32714 item 2] The database half of the registry's take-over rule
* (`package-slotted-store.test.ts`): a package's first item of a name whose
* bundle the environment holds binds the package-less row of the member it
* writes, and leaves the other members' rows package-less, as the registry
* and the door's `MemoryLoader` leave their keys.
*/
it.each(BUNDLED)('%s: [#32714] a package\'s first member of an environment-held bundle binds that member\'s row only; the other member\'s row stays package-less', async (type) => {
const { driver, rows } = rowDriver();
const db = new DatabaseLoader({ driver, tableName: 'sys_metadata' });
const body = (packageId: string | undefined, locale: string) => ({
name: nameOf(type), label: `${packageId ?? 'env'}:${locale}`, locale,
...(packageId === undefined ? {} : { _packageId: packageId, _provenance: 'package' }),
});
await db.save(type, nameOf(type), body(undefined, 'en-US'));
await db.save(type, nameOf(type), body(undefined, 'fr-FR'));
await db.save(type, nameOf(type), body(A, 'en-US'));
const stored = rows(type).map((row) => [row.package_id ?? null, row.bundle_member ?? null]).sort((x, y) => String(x[1]).localeCompare(String(y[1])));
expect(stored).toEqual([[null, 'fr-FR'], [A, null]]);
});

it.each(UNBUNDLED)('%s: the loaders\' by-name reads still answer the item saved last; the plural reads answer every package\'s item, over the door\'s store ([#32517] under epic record 6818307202 on #32517: an unscoped plural read answers every package\'s slot) and over the stored rows (#32521; BEFORE: the database\'s one row per name, the one saved last)', async (type) => {
const ctx = await boot(type);
for (const peer of [ctx.peer(['database']), ctx.peer(['door'])]) {
expect(((await peer.load(type, nameOf(type))) as any)?.label).toBe(label(type, B));
Expand Down
5 changes: 4 additions & 1 deletion packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
slotPackageOf,
slotOfKey,
slotStoreOf,
newSlotView,
storedItemPackageId as indexedItemPackageId,
storedNameOf,
storeKeysNamed,
Expand Down Expand Up @@ -1550,7 +1551,9 @@ export class MetadataManager implements IMetadataService {
loaderStore: ReadonlyMap<string, unknown>,
): Map<string, unknown> {
const registryStore = this.registry.get(type);
const view = new Map<string, unknown>();
// [#32714] Declared a view where it is made (`newSlotView`), so
// `latestMemberOf` never infers it from a missing write order.
const view = newSlotView<unknown>();
// [#32517] Each slot's member written last first: a slot no key of which
// came from the registry answers its first member (`latestMemberOf`).
if (registryStore) {
Expand Down
Loading
Loading