|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Conformance pin — `getObject(name)` ≡ `get('object', name)` (#6745). |
| 5 | + * |
| 6 | + * `IMetadataService.getObject` (`packages/spec/src/contracts/metadata-service.ts`) |
| 7 | + * DECLARES that the pair resolves through one lookup in every implementation this |
| 8 | + * repo ships, and that both members hand back the identical object. PR #6723 (for |
| 9 | + * #6505) wrote that down after measuring it with a throwaway probe, which left the |
| 10 | + * statement declared-but-ungated: nothing failed if a later edit made the pair |
| 11 | + * diverge, and the contract TSDoc would then simply be lying. This file is the gate. |
| 12 | + * |
| 13 | + * `packages/objectql` is the only package that can see all three implementations — |
| 14 | + * it depends on both `@objectstack/metadata` (MetadataManager) and |
| 15 | + * `@objectstack/core` (createMemoryMetadata), and owns MetadataFacade itself. |
| 16 | + * `packages/spec` cannot host it: the contract has no runtime. |
| 17 | + * |
| 18 | + * Two things about the shape here are load-bearing, and both are the difference |
| 19 | + * between this pin and a green-but-empty one: |
| 20 | + * |
| 21 | + * 1. **The facade is seeded through `registry.registerObject`, never through |
| 22 | + * `facade.register('object', …)`.** Those are not interchangeable: the facade |
| 23 | + * writes objects through `SchemaRegistry.registerItem`, which stores into the |
| 24 | + * generic `metadata` map, while BOTH of its object reads resolve from |
| 25 | + * `objectContributors` (`registry.getItem` special-cases the `object` type |
| 26 | + * straight back to `registry.getObject`). An object written the first way is |
| 27 | + * readable back through neither member — measured, both `undefined` — so a pin |
| 28 | + * that seeded that way would compare `undefined` to `undefined` and call it |
| 29 | + * equivalence. That write/read split is a separate, already-filed finding |
| 30 | + * (#6725); this file deliberately does not assert on it, and the |
| 31 | + * `expect(...).toBeDefined()` in the present-object case is what stops the |
| 32 | + * vacuous version from ever passing here again. |
| 33 | + * |
| 34 | + * 2. **MetadataManager appears twice, under both of its resolution paths.** Its |
| 35 | + * `get` answers from the in-memory registry when it can and falls back to the |
| 36 | + * loaders otherwise; `getObject` delegates to `get`, so a future edit that |
| 37 | + * re-implemented `getObject` against the registry alone would still agree on |
| 38 | + * every registry-seeded case and diverge only on a loader-backed one. Seeding |
| 39 | + * one subject through each path is what makes that break visible. |
| 40 | + * |
| 41 | + * Reference identity (`toBe`), not deep equality, is the assertion because |
| 42 | + * identity is what each implementation's mechanism actually delivers today — |
| 43 | + * measured on all four subjects, on both call orders — and it is what the contract |
| 44 | + * claims. `MetadataManager.getObject` literally returns `this.get('object', name)`; |
| 45 | + * `createMemoryMetadata` reads one `Map` from both members; the facade's two paths |
| 46 | + * converge on `SchemaRegistry.getObject`, whose merge result is memoized in |
| 47 | + * `mergedObjectCache`, so both members hand back the same instance. |
| 48 | + * |
| 49 | + * Refs #6745, #6505, PR #6723, #6725. |
| 50 | + */ |
| 51 | + |
| 52 | +import { describe, it, expect } from 'vitest'; |
| 53 | +import type { IMetadataService } from '@objectstack/spec/contracts'; |
| 54 | +import { SchemaRegistry } from './registry'; |
| 55 | +import { MetadataFacade } from './metadata-facade'; |
| 56 | +import { MetadataManager, type MetadataLoader } from '@objectstack/metadata'; |
| 57 | +import { createMemoryMetadata } from '@objectstack/core'; |
| 58 | + |
| 59 | +/** |
| 60 | + * The two contract members under test, and nothing else. Typing the subjects |
| 61 | + * against `IMetadataService` rather than against the concrete classes is |
| 62 | + * deliberate: it is the contract's declaration this file is pinning, so a |
| 63 | + * signature change on either member should reach this file through `tsc`. |
| 64 | + */ |
| 65 | +type ObjectResolvingService = Pick<IMetadataService, 'get' | 'getObject'>; |
| 66 | + |
| 67 | +interface ObjectFixture { |
| 68 | + readonly name: string; |
| 69 | + readonly definition: Record<string, unknown>; |
| 70 | +} |
| 71 | + |
| 72 | +const objectFixture = (name: string): ObjectFixture => ({ |
| 73 | + name, |
| 74 | + definition: { |
| 75 | + name, |
| 76 | + label: name.replace(/_/g, ' '), |
| 77 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 78 | + }, |
| 79 | +}); |
| 80 | + |
| 81 | +/** |
| 82 | + * A shipped implementation plus the seeding channel ITS OWN object reads observe. |
| 83 | + * The channel is part of the subject, not an incidental detail — see note 1 in |
| 84 | + * the file header. |
| 85 | + */ |
| 86 | +interface PinnedImplementation { |
| 87 | + readonly label: string; |
| 88 | + create(objects: readonly ObjectFixture[]): Promise<ObjectResolvingService>; |
| 89 | +} |
| 90 | + |
| 91 | +/** Minimal read-only loader, so MetadataManager's loader-fallback path is real. */ |
| 92 | +class FixtureLoader implements MetadataLoader { |
| 93 | + readonly contract: MetadataLoader['contract'] = { |
| 94 | + name: 'getobject-equivalence-fixture', |
| 95 | + protocol: 'memory:', |
| 96 | + capabilities: { read: true, write: false, watch: false, list: true }, |
| 97 | + }; |
| 98 | + |
| 99 | + private readonly storage = new Map<string, unknown>(); |
| 100 | + |
| 101 | + constructor(objects: readonly ObjectFixture[]) { |
| 102 | + for (const object of objects) { |
| 103 | + this.storage.set(object.name, object.definition); |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + async load(type: string, name: string) { |
| 108 | + const data = type === 'object' ? this.storage.get(name) : undefined; |
| 109 | + return data |
| 110 | + ? { data, source: this.contract.name, format: 'json' as const, loadTime: 0 } |
| 111 | + : { data: null }; |
| 112 | + } |
| 113 | + |
| 114 | + async loadMany<T = unknown>(type: string): Promise<T[]> { |
| 115 | + return (type === 'object' ? Array.from(this.storage.values()) : []) as T[]; |
| 116 | + } |
| 117 | + |
| 118 | + async exists(type: string, name: string): Promise<boolean> { |
| 119 | + return type === 'object' && this.storage.has(name); |
| 120 | + } |
| 121 | + |
| 122 | + async stat() { |
| 123 | + return null; |
| 124 | + } |
| 125 | + |
| 126 | + async list(type: string): Promise<string[]> { |
| 127 | + return type === 'object' ? Array.from(this.storage.keys()) : []; |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +const IMPLEMENTATIONS: readonly PinnedImplementation[] = [ |
| 132 | + { |
| 133 | + // `get` answers from the in-memory registry on this one. |
| 134 | + label: 'MetadataManager (registry hit)', |
| 135 | + async create(objects) { |
| 136 | + const manager = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 137 | + for (const object of objects) { |
| 138 | + await manager.register('object', object.name, object.definition); |
| 139 | + } |
| 140 | + return manager; |
| 141 | + }, |
| 142 | + }, |
| 143 | + { |
| 144 | + // Nothing is registered, so `get` can only answer through the loaders — |
| 145 | + // the second of MetadataManager's two resolution paths. |
| 146 | + label: 'MetadataManager (loader fallback)', |
| 147 | + async create(objects) { |
| 148 | + return new MetadataManager({ formats: ['json'], loaders: [new FixtureLoader(objects)] }); |
| 149 | + }, |
| 150 | + }, |
| 151 | + { |
| 152 | + label: 'createMemoryMetadata', |
| 153 | + async create(objects) { |
| 154 | + const memory = createMemoryMetadata(); |
| 155 | + for (const object of objects) { |
| 156 | + await memory.register('object', object.name, object.definition); |
| 157 | + } |
| 158 | + return memory; |
| 159 | + }, |
| 160 | + }, |
| 161 | + { |
| 162 | + label: 'MetadataFacade', |
| 163 | + async create(objects) { |
| 164 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 165 | + for (const object of objects) { |
| 166 | + // NOT `facade.register('object', …)` — that writes where neither of |
| 167 | + // the facade's object reads look (#6725), which would make the |
| 168 | + // present-object case below compare undefined to undefined. |
| 169 | + registry.registerObject(object.definition as never, 'com.example.pin'); |
| 170 | + } |
| 171 | + return new MetadataFacade(registry); |
| 172 | + }, |
| 173 | + }, |
| 174 | +]; |
| 175 | + |
| 176 | +describe.each(IMPLEMENTATIONS)( |
| 177 | + 'IMetadataService conformance — getObject(n) ≡ get(\'object\', n) [$label]', |
| 178 | + ({ create }) => { |
| 179 | + it('answers a present object identically through both members', async () => { |
| 180 | + const alpha = objectFixture('pin_alpha'); |
| 181 | + const service = await create([alpha]); |
| 182 | + |
| 183 | + const viaGetObject = await service.getObject(alpha.name); |
| 184 | + const viaGet = await service.get('object', alpha.name); |
| 185 | + |
| 186 | + // Anti-vacuity: without this, a subject that resolved NOTHING would |
| 187 | + // satisfy the equivalence below by answering undefined twice. |
| 188 | + expect(viaGetObject).toBeDefined(); |
| 189 | + expect((viaGetObject as { name?: string }).name).toBe(alpha.name); |
| 190 | + |
| 191 | + expect(viaGetObject).toBe(viaGet); |
| 192 | + }); |
| 193 | + |
| 194 | + it('answers undefined through both members for an object nothing registered', async () => { |
| 195 | + const service = await create([objectFixture('pin_alpha')]); |
| 196 | + |
| 197 | + const viaGetObject = await service.getObject('pin_absent'); |
| 198 | + const viaGet = await service.get('object', 'pin_absent'); |
| 199 | + |
| 200 | + expect(viaGetObject).toBeUndefined(); |
| 201 | + expect(viaGet).toBeUndefined(); |
| 202 | + }); |
| 203 | + |
| 204 | + it('keeps the pair name-discriminating when several objects are registered', async () => { |
| 205 | + const alpha = objectFixture('pin_alpha'); |
| 206 | + const beta = objectFixture('pin_beta'); |
| 207 | + const service = await create([alpha, beta]); |
| 208 | + |
| 209 | + const alphaViaGetObject = await service.getObject(alpha.name); |
| 210 | + const alphaViaGet = await service.get('object', alpha.name); |
| 211 | + const betaViaGetObject = await service.getObject(beta.name); |
| 212 | + const betaViaGet = await service.get('object', beta.name); |
| 213 | + |
| 214 | + expect(alphaViaGetObject).toBeDefined(); |
| 215 | + expect(betaViaGetObject).toBeDefined(); |
| 216 | + |
| 217 | + expect(alphaViaGetObject).toBe(alphaViaGet); |
| 218 | + expect(betaViaGetObject).toBe(betaViaGet); |
| 219 | + |
| 220 | + // A member that ignored `name` and returned the first object would |
| 221 | + // agree with itself on every case above; it cannot survive this one. |
| 222 | + expect(alphaViaGetObject).not.toBe(betaViaGetObject); |
| 223 | + expect((alphaViaGetObject as { name?: string }).name).toBe(alpha.name); |
| 224 | + expect((betaViaGetObject as { name?: string }).name).toBe(beta.name); |
| 225 | + }); |
| 226 | + }, |
| 227 | +); |
0 commit comments