|
29 | 29 | * shape could not pin it. It is now asserted where the ordering is decided: |
30 | 30 | * SYNCHRONOUSLY, inside the callback, against the registry `register()` writes. |
31 | 31 | * |
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. |
38 | 43 | */ |
39 | 44 |
|
40 | 45 | 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'; |
41 | 53 | import { MetadataManager } from './metadata-manager'; |
42 | 54 | 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'; |
43 | 63 | import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
44 | 64 |
|
45 | 65 | vi.mock('@objectstack/core', () => ({ |
@@ -68,6 +88,105 @@ const registryHas = (mgr: MetadataManager, type: string, name: string): boolean |
68 | 88 | const registryPeek = (mgr: MetadataManager, type: string, name: string): unknown => |
69 | 89 | registryOf(mgr).get(type)?.get(name); |
70 | 90 |
|
| 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 | + |
71 | 190 | describe('#3112 — register()/unregister() notify subscribe() watchers', () => { |
72 | 191 | let manager: MetadataManager; |
73 | 192 |
|
@@ -342,3 +461,145 @@ describe('#3112 — register()/unregister() notify subscribe() watchers', () => |
342 | 461 | }); |
343 | 462 | }); |
344 | 463 | }); |
| 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