Skip to content

Commit 1f65bfc

Browse files
baozhoutaoclaude
andauthored
test(metadata): pin the writable-loader half of register()'s announcement ordering (#6548) (#6607)
* test(metadata): pin the writable-loader half of register()'s announcement ordering (#6548) register() documents that it announces "once the write has landed in the registry and every writable loader". After #6043/PR #6547 only the registry half was observable: this file's sole loader is a MemoryLoader (`memory:`) and register() persists to `datasource:` loaders only, so hoisting the whole notifyWatchers block above the save loop left every case green. Adds a writable `datasource:` fixture backed by a real store (donor shape from metadata-manager-unregister-invalidate-order.test.ts) and peeks that store SYNCHRONOUSLY inside the watcher callback — the same instant #6043 used for the registry. Five cases: single loader, overwrite (readable failure), every loader rather than the first, an in-flight save holding the announcement back, and { notify: false } silence with the loader write still landing. No source change: the loader half is pinned, not narrowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA * test(metadata): keep the new pin out of the package's frozen type-check debt (#6548) Measured: as first written the five cases added 3 raw tsc errors to @objectstack/metadata's DEBT ledger entry (1 TS2835 from an extensionless `./loaders/loader-interface` import, 2 TS7006 from the un-annotated watcher callbacks the unresolved `./metadata-manager` import cascades into). Spelling the new import with `.js` and annotating the two callbacks with MetadataWatchEvent puts the file back on its origin/main composition exactly — 2 TS2835 + 14 TS7006, unchanged — so the pin adds zero debt. The `.js` also makes `implements MetadataLoader` on the fixture a real check rather than a comment over an `any`. The two pre-existing extensionless imports are #4311's ledger and are left alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e39dd66 commit 1f65bfc

1 file changed

Lines changed: 267 additions & 6 deletions

File tree

packages/metadata/src/register-notifies-watchers.test.ts

Lines changed: 267 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,37 @@
2929
* shape could not pin it. It is now asserted where the ordering is decided:
3030
* SYNCHRONOUSLY, inside the callback, against the registry `register()` writes.
3131
*
32-
* Scope note: `register()` claims the announcement follows the write into the
33-
* registry AND into every writable loader. Only the registry half is pinned
34-
* here — this fixture's `MemoryLoader` declares `memory:`, and `register()`
35-
* persists to `datasource:` loaders only, so no loader in this file is ever
36-
* written to and the second half is not observable from it. Tracked separately;
37-
* it needs a writable-datasource fixture rather than another assertion.
32+
* [#6548] `register()` claims the announcement follows the write into the
33+
* registry AND into every writable loader. After #6043 only the REGISTRY half
34+
* was pinned: the suite's shared `MemoryLoader` declares `memory:` and
35+
* `register()` persists to `datasource:` loaders only, so no loader in the file
36+
* was ever written to — hoisting the whole `notifyWatchers(...)` block above the
37+
* save loop, a violation of the documented ordering, left all 15 cases GREEN.
38+
* The loader half is now pinned too, in the last describe block below, with the
39+
* same shape one store over: a writable `datasource:` fixture whose `save()`
40+
* lands the row before it resolves, peeked SYNCHRONOUSLY inside the watcher
41+
* callback. Nothing about the guarantee was narrowed — both halves are asserted,
42+
* so the comment on `register()` is now enforced as written.
3843
*/
3944

4045
import { describe, it, expect, vi, beforeEach } from 'vitest';
46+
import type {
47+
MetadataLoadResult,
48+
MetadataLoaderContract,
49+
MetadataSaveResult,
50+
MetadataStats,
51+
MetadataWatchEvent,
52+
} from '@objectstack/spec/system';
4153
import { MetadataManager } from './metadata-manager';
4254
import { MemoryLoader } from './loaders/memory-loader';
55+
// `.js` deliberately, unlike the three extensionless imports above it: under
56+
// `moduleResolution: nodenext` an extensionless relative import does not
57+
// resolve, and every symbol it names silently becomes `any` (AGENTS.md, the
58+
// TS7006 cascade). Spelling this one correctly is what makes `implements
59+
// MetadataLoader` on the fixture below an actual check rather than decoration.
60+
// The three above are this package's pre-existing type-check debt (#4311) and
61+
// are left for whoever pays that ledger down.
62+
import type { MetadataLoader } from './loaders/loader-interface.js';
4363
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
4464

4565
vi.mock('@objectstack/core', () => ({
@@ -68,6 +88,105 @@ const registryHas = (mgr: MetadataManager, type: string, name: string): boolean
6888
const registryPeek = (mgr: MetadataManager, type: string, name: string): unknown =>
6989
registryOf(mgr).get(type)?.get(name);
7090

91+
/**
92+
* [#6548] A gate a test opens by hand, so nothing depends on timer ordering.
93+
* Donor shape, verbatim: `metadata-manager-unregister-invalidate-order.test.ts`.
94+
*/
95+
class Gate {
96+
private parked: Array<() => void> = [];
97+
closed = false;
98+
async pass(): Promise<void> {
99+
if (!this.closed) return;
100+
await new Promise<void>((resolve) => this.parked.push(resolve));
101+
}
102+
get parkedCount(): number {
103+
return this.parked.length;
104+
}
105+
releaseAll(): void {
106+
const parked = this.parked;
107+
this.parked = [];
108+
for (const resolve of parked) resolve();
109+
}
110+
}
111+
112+
/**
113+
* [#6548] A writable `datasource:` loader backed by a real store — the ONLY
114+
* shape `register()` actually persists into, and therefore the only fixture
115+
* from which the loader half of the ordering claim is observable at all. Donor:
116+
* `StoreLoader` in `metadata-manager-unregister-invalidate-order.test.ts`,
117+
* trimmed to the write path (that file's read/delete gates model an
118+
* `unregister()` race this file has no case for).
119+
*
120+
* `save()` awaits before it seeds, deliberately. A real store write is async,
121+
* and the hop is what makes the synchronous peek below able to SEE a
122+
* `register()` that fired the save without awaiting it: the row would not be in
123+
* the store yet at broadcast time. It still lands the write before it resolves,
124+
* so with the gate open every assertion is deterministic rather than timed.
125+
*/
126+
class StoreLoader implements MetadataLoader {
127+
readonly contract: MetadataLoaderContract;
128+
/** type → name → data. */
129+
readonly store = new Map<string, Map<string, unknown>>();
130+
readonly writeGate = new Gate();
131+
132+
constructor(name = 'db') {
133+
this.contract = {
134+
name,
135+
protocol: 'datasource:',
136+
capabilities: { read: true, write: true, watch: false, list: true },
137+
};
138+
}
139+
140+
has(type: string, name: string): boolean {
141+
return this.store.get(type)?.has(name) ?? false;
142+
}
143+
144+
peek(type: string, name: string): unknown {
145+
return this.store.get(type)?.get(name);
146+
}
147+
148+
async save(type: string, name: string, data: unknown): Promise<MetadataSaveResult> {
149+
await this.writeGate.pass();
150+
if (!this.store.has(type)) this.store.set(type, new Map());
151+
this.store.get(type)!.set(name, data);
152+
return { success: true };
153+
}
154+
155+
// Required by `assertWritableLoaderContract` for a writable `datasource:`
156+
// loader (#5276/#5654) — this file never drives it.
157+
async delete(type: string, name: string): Promise<void> {
158+
this.store.get(type)?.delete(name);
159+
}
160+
161+
async load(type: string, name: string): Promise<MetadataLoadResult> {
162+
return { data: this.peek(type, name) ?? null };
163+
}
164+
async loadMany<T = unknown>(type: string): Promise<T[]> {
165+
return Array.from(this.store.get(type)?.values() ?? []) as T[];
166+
}
167+
async exists(type: string, name: string): Promise<boolean> {
168+
return this.has(type, name);
169+
}
170+
async stat(): Promise<MetadataStats | null> {
171+
return null;
172+
}
173+
async list(type: string): Promise<string[]> {
174+
return Array.from(this.store.get(type)?.keys() ?? []);
175+
}
176+
}
177+
178+
/** Give queued microtasks a chance to run. */
179+
const flush = async (): Promise<void> => {
180+
for (let i = 0; i < 8; i++) await Promise.resolve();
181+
};
182+
183+
const managerOver = (...loaders: MetadataLoader[]): MetadataManager => {
184+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
185+
for (const loader of loaders) mgr.registerLoader(loader);
186+
mgr.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
187+
return mgr;
188+
};
189+
71190
describe('#3112 — register()/unregister() notify subscribe() watchers', () => {
72191
let manager: MetadataManager;
73192

@@ -342,3 +461,145 @@ describe('#3112 — register()/unregister() notify subscribe() watchers', () =>
342461
});
343462
});
344463
});
464+
465+
/**
466+
* #6548 — the OTHER half of the same sentence.
467+
*
468+
* `register()` promises the announcement lands "once the write has landed in the
469+
* registry and every writable loader". #6043 pinned the registry half where the
470+
* ordering is decided — synchronously, inside the callback — and said in its own
471+
* scope note that the loader half was not observable from a fixture whose only
472+
* loader speaks `memory:`. It was not observable anywhere else either: hoisting
473+
* the announcement above the save loop kept the whole file green.
474+
*
475+
* These cases close that. Same instant, same technique, one store over: the peek
476+
* is at the LOADER's store, taken synchronously inside the watcher callback, so
477+
* no `await` hop of any consumer method can move the verdict.
478+
*
479+
* Why it is worth pinning even though no consumer reads through a loader today
480+
* (`get()` resolves against the registry, which outranks every loader): the
481+
* ordering is what the method DECLARES, and a declaration nothing can falsify is
482+
* the shape this repo keeps paying to rediscover. The cost of the gap was
483+
* already concrete once — #5840 abandoned a correct refactor because the old,
484+
* frame-counting version of the registry case went red on it while the real
485+
* invariant went unmeasured.
486+
*/
487+
describe('#6548 — register() announces only once every WRITABLE LOADER holds the write', () => {
488+
it('the loader store already holds the new body at broadcast time', async () => {
489+
const loader = new StoreLoader();
490+
const manager = managerOver(loader);
491+
492+
const atBroadcast: Array<{ had: boolean; body: unknown }> = [];
493+
manager.subscribe('object', () => {
494+
atBroadcast.push({
495+
had: loader.has('object', 'account'),
496+
body: loader.peek('object', 'account'),
497+
});
498+
});
499+
500+
await manager.register('object', 'account', { name: 'account', label: 'Fresh' });
501+
502+
expect(atBroadcast).toHaveLength(1);
503+
expect(atBroadcast[0].had).toBe(true);
504+
expect(atBroadcast[0].body).toEqual({ name: 'account', label: 'Fresh' });
505+
// The control: the fixture really is on the persistence path, so a green
506+
// `had: true` above cannot be a loader that was never written to.
507+
expect(loader.peek('object', 'account')).toEqual({ name: 'account', label: 'Fresh' });
508+
});
509+
510+
it('on an OVERWRITE too — never with the pre-write row still in the store', async () => {
511+
// The overwrite half exists so the failure is READABLE, exactly as in the
512+
// registry case above: on a first registration an early announcement reads
513+
// `undefined`, which is also what "no loader involved" reads as; here the
514+
// pre-write row is a distinct value, so announcing too early fails with 'V1'
515+
// where 'V2' was required and names the defect on sight.
516+
const loader = new StoreLoader();
517+
const manager = managerOver(loader);
518+
await manager.register('object', 'account', { name: 'account', label: 'V1' }, { notify: false });
519+
expect(loader.peek('object', 'account')).toEqual({ name: 'account', label: 'V1' });
520+
521+
let bodyAtBroadcast: unknown;
522+
manager.subscribe('object', () => {
523+
bodyAtBroadcast = loader.peek('object', 'account');
524+
});
525+
526+
await manager.register('object', 'account', { name: 'account', label: 'V2' });
527+
528+
expect(bodyAtBroadcast).toEqual({ name: 'account', label: 'V2' });
529+
});
530+
531+
it('EVERY writable loader, not merely the first — both stores hold it at broadcast time', async () => {
532+
// The word in the comment is "every". An announcement moved INSIDE the save
533+
// loop would satisfy a single-loader case and fail here, with the second
534+
// store still empty.
535+
const first = new StoreLoader('db_a');
536+
const second = new StoreLoader('db_b');
537+
const manager = managerOver(first, second);
538+
539+
const atBroadcast: Array<{ a: unknown; b: unknown }> = [];
540+
manager.subscribe('object', () => {
541+
atBroadcast.push({
542+
a: first.peek('object', 'account'),
543+
b: second.peek('object', 'account'),
544+
});
545+
});
546+
547+
await manager.register('object', 'account', { name: 'account', label: 'Fresh' });
548+
549+
expect(atBroadcast).toHaveLength(1);
550+
expect(atBroadcast[0]).toEqual({
551+
a: { name: 'account', label: 'Fresh' },
552+
b: { name: 'account', label: 'Fresh' },
553+
});
554+
});
555+
556+
it('a loader save still IN FLIGHT holds the announcement back — the save window broadcasts nothing', async () => {
557+
// The temporal statement the peeks above cannot make on their own: not
558+
// "when it fired, the store was written" but "it had not fired yet while a
559+
// writable loader was still mid-write". Parking one store's save opens that
560+
// window by hand, so the assertion does not depend on how many microtask
561+
// hops `register()` happens to have.
562+
const fast = new StoreLoader('db_fast');
563+
const slow = new StoreLoader('db_slow');
564+
const manager = managerOver(fast, slow);
565+
566+
const seen: MetadataWatchEvent[] = [];
567+
manager.subscribe('object', (event: MetadataWatchEvent) => {
568+
seen.push(event);
569+
});
570+
571+
slow.writeGate.closed = true;
572+
const write = manager.register('object', 'account', { name: 'account', label: 'Fresh' });
573+
await flush();
574+
575+
// Inside the window: one store written, the other parked mid-save.
576+
expect(slow.writeGate.parkedCount).toBe(1);
577+
expect(fast.has('object', 'account')).toBe(true);
578+
expect(slow.has('object', 'account')).toBe(false);
579+
expect(seen).toHaveLength(0);
580+
581+
slow.writeGate.releaseAll();
582+
await write;
583+
584+
expect(seen).toHaveLength(1);
585+
expect(seen[0]).toMatchObject({ type: 'added', metadataType: 'object', name: 'account' });
586+
expect(slow.peek('object', 'account')).toEqual({ name: 'account', label: 'Fresh' });
587+
});
588+
589+
it('{ notify: false } still suppresses the announcement — and the loader write still lands', async () => {
590+
// Silence is opt-in, never "skipped": the half this file pins for the
591+
// registry, asserted for the store the loader half is about.
592+
const loader = new StoreLoader();
593+
const manager = managerOver(loader);
594+
595+
const seen: MetadataWatchEvent[] = [];
596+
manager.subscribe('object', (event: MetadataWatchEvent) => {
597+
seen.push(event);
598+
});
599+
600+
await manager.register('object', 'account', { name: 'account' }, { notify: false });
601+
602+
expect(seen).toHaveLength(0);
603+
expect(loader.peek('object', 'account')).toEqual({ name: 'account' });
604+
});
605+
});

0 commit comments

Comments
 (0)