|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #5276 — `capabilities.write` means BOTH directions, and registration enforces it. |
| 5 | + * |
| 6 | + * `MetadataLoader` declared `save?` and no `delete`, so `capabilities.write` |
| 7 | + * meant two different things at the two ends of an item's life: to |
| 8 | + * `register()` it meant "persist into me", and to `unregister()` it guaranteed |
| 9 | + * nothing at all. `unregister()` duck-typed `delete` at the call site and, when |
| 10 | + * the loader had none, **silently skipped it** — then dropped the registry |
| 11 | + * entry, invalidated the list cache and announced a `deleted` event anyway. The |
| 12 | + * caller was told the delete succeeded; the row stayed in the loader and was |
| 13 | + * read straight back out by the next `list()`/`get()`, across restarts, with |
| 14 | + * nothing to retry it. Standard declared ≠ enforced (Prime Directive #10). |
| 15 | + * |
| 16 | + * The fix enforces the declaration where the author is standing: |
| 17 | + * 1. `MetadataLoader` now declares `delete?(type, name): Promise<void>` — the |
| 18 | + * contract states the capability instead of leaving each caller to guess; |
| 19 | + * 2. `registerLoader()` REJECTS a `datasource:` loader that declares |
| 20 | + * `capabilities.write` without a `delete()` method, loudly, naming the |
| 21 | + * consequence and both ways out. `registerLoader()` is the sole writer of |
| 22 | + * the loader map (the constructor's `config.loaders` funnel through it), |
| 23 | + * so the rejected combination cannot reach the runtime at all; |
| 24 | + * 3. `unregister()`'s `typeof … === 'function'` guard stays as defensive code |
| 25 | + * whose unreachability is now guaranteed by construction. |
| 26 | + * |
| 27 | + * What these tests pin: |
| 28 | + * 1. the rejection, on both entry points (constructor config and the direct |
| 29 | + * `registerLoader()` call), including that nothing is half-registered; |
| 30 | + * 2. the message is actionable — it names the loader and BOTH repairs; |
| 31 | + * 3. the positive case is untouched: a writable datasource loader WITH |
| 32 | + * `delete` registers and `unregister()` really calls it; |
| 33 | + * 4. the gate's scope is exactly the combination `unregister()` acts on — |
| 34 | + * a read-only `datasource:` loader and every non-`datasource:` protocol |
| 35 | + * register without a `delete`, because the manager never writes to them; |
| 36 | + * 5. `DatabaseLoader`, the repo's only real `datasource:` loader, passes the |
| 37 | + * gate unchanged. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 41 | +import type { |
| 42 | + MetadataLoadResult, |
| 43 | + MetadataLoaderContract, |
| 44 | + MetadataSaveResult, |
| 45 | + MetadataStats, |
| 46 | +} from '@objectstack/spec/system'; |
| 47 | +import type { IDataDriver } from '@objectstack/spec/contracts'; |
| 48 | +import { MetadataManager } from './metadata-manager.js'; |
| 49 | +import { DatabaseLoader } from './loaders/database-loader.js'; |
| 50 | +import type { MetadataLoader } from './loaders/loader-interface.js'; |
| 51 | + |
| 52 | +const logger = vi.hoisted(() => ({ |
| 53 | + info: vi.fn(), |
| 54 | + warn: vi.fn(), |
| 55 | + error: vi.fn(), |
| 56 | + debug: vi.fn(), |
| 57 | +})); |
| 58 | + |
| 59 | +vi.mock('@objectstack/core', () => ({ |
| 60 | + createLogger: () => logger, |
| 61 | +})); |
| 62 | + |
| 63 | +type Protocol = MetadataLoaderContract['protocol']; |
| 64 | + |
| 65 | +/** |
| 66 | + * A loader whose contract is dictated per test and whose `delete` is present or |
| 67 | + * absent on demand — the two axes the gate reads, and nothing else. |
| 68 | + */ |
| 69 | +function makeLoader(opts: { |
| 70 | + name: string; |
| 71 | + protocol: Protocol; |
| 72 | + write: boolean; |
| 73 | + withDelete: boolean; |
| 74 | +}): MetadataLoader & { deleteCalls: Array<[string, string]>; saveCalls: Array<[string, string]> } { |
| 75 | + const deleteCalls: Array<[string, string]> = []; |
| 76 | + const saveCalls: Array<[string, string]> = []; |
| 77 | + const store = new Map<string, unknown>(); |
| 78 | + const key = (type: string, name: string) => `${type}/${name}`; |
| 79 | + |
| 80 | + const loader: MetadataLoader & { |
| 81 | + deleteCalls: Array<[string, string]>; |
| 82 | + saveCalls: Array<[string, string]>; |
| 83 | + } = { |
| 84 | + contract: { |
| 85 | + name: opts.name, |
| 86 | + protocol: opts.protocol, |
| 87 | + capabilities: { read: true, write: opts.write, watch: false, list: true }, |
| 88 | + }, |
| 89 | + deleteCalls, |
| 90 | + saveCalls, |
| 91 | + async load(type: string, name: string): Promise<MetadataLoadResult> { |
| 92 | + const data = store.get(key(type, name)); |
| 93 | + return data === undefined ? { data: null } : { data }; |
| 94 | + }, |
| 95 | + async loadMany<T = unknown>(): Promise<T[]> { |
| 96 | + return Array.from(store.values()) as T[]; |
| 97 | + }, |
| 98 | + async exists(type: string, name: string): Promise<boolean> { |
| 99 | + return store.has(key(type, name)); |
| 100 | + }, |
| 101 | + async stat(): Promise<MetadataStats | null> { |
| 102 | + return null; |
| 103 | + }, |
| 104 | + async list(): Promise<string[]> { |
| 105 | + return []; |
| 106 | + }, |
| 107 | + async save(type: string, name: string, data: unknown): Promise<MetadataSaveResult> { |
| 108 | + saveCalls.push([type, name]); |
| 109 | + store.set(key(type, name), data); |
| 110 | + return { success: true }; |
| 111 | + }, |
| 112 | + }; |
| 113 | + |
| 114 | + if (opts.withDelete) { |
| 115 | + loader.delete = async (type: string, name: string): Promise<void> => { |
| 116 | + deleteCalls.push([type, name]); |
| 117 | + store.delete(key(type, name)); |
| 118 | + }; |
| 119 | + } |
| 120 | + |
| 121 | + return loader; |
| 122 | +} |
| 123 | + |
| 124 | +/** Read the manager's private loader map — the thing registration writes. */ |
| 125 | +const registeredLoaderNames = (mgr: MetadataManager): string[] => |
| 126 | + Array.from((mgr as unknown as { loaders: Map<string, unknown> }).loaders.keys()); |
| 127 | + |
| 128 | +beforeEach(() => { |
| 129 | + logger.info.mockClear(); |
| 130 | + logger.warn.mockClear(); |
| 131 | + logger.error.mockClear(); |
| 132 | + logger.debug.mockClear(); |
| 133 | +}); |
| 134 | + |
| 135 | +describe("a `datasource:` loader that declares `capabilities.write` MUST implement `delete()`", () => { |
| 136 | + it('registerLoader() throws rather than accepting a loader it can never delete from', () => { |
| 137 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 138 | + const undeletable = makeLoader({ |
| 139 | + name: 'half_writable_store', |
| 140 | + protocol: 'datasource:', |
| 141 | + write: true, |
| 142 | + withDelete: false, |
| 143 | + }); |
| 144 | + |
| 145 | + expect(() => mgr.registerLoader(undeletable)).toThrow(/half_writable_store/); |
| 146 | + }); |
| 147 | + |
| 148 | + it('…and nothing is half-registered — the rejected loader is not in the map', () => { |
| 149 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 150 | + const undeletable = makeLoader({ |
| 151 | + name: 'half_writable_store', |
| 152 | + protocol: 'datasource:', |
| 153 | + write: true, |
| 154 | + withDelete: false, |
| 155 | + }); |
| 156 | + |
| 157 | + expect(() => mgr.registerLoader(undeletable)).toThrow(); |
| 158 | + expect(registeredLoaderNames(mgr)).not.toContain('half_writable_store'); |
| 159 | + }); |
| 160 | + |
| 161 | + it('the constructor rejects it too — `config.loaders` is not a back door', () => { |
| 162 | + const undeletable = makeLoader({ |
| 163 | + name: 'half_writable_store', |
| 164 | + protocol: 'datasource:', |
| 165 | + write: true, |
| 166 | + withDelete: false, |
| 167 | + }); |
| 168 | + |
| 169 | + expect( |
| 170 | + () => new MetadataManager({ formats: ['json'], loaders: [undeletable] }), |
| 171 | + ).toThrow(/half_writable_store/); |
| 172 | + }); |
| 173 | + |
| 174 | + it('the message names the loader, the consequence, and BOTH repairs', () => { |
| 175 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 176 | + const undeletable = makeLoader({ |
| 177 | + name: 'half_writable_store', |
| 178 | + protocol: 'datasource:', |
| 179 | + write: true, |
| 180 | + withDelete: false, |
| 181 | + }); |
| 182 | + |
| 183 | + let message = ''; |
| 184 | + try { |
| 185 | + mgr.registerLoader(undeletable); |
| 186 | + } catch (error) { |
| 187 | + message = error instanceof Error ? error.message : String(error); |
| 188 | + } |
| 189 | + |
| 190 | + // Which loader, and what it declared. |
| 191 | + expect(message).toContain('half_writable_store'); |
| 192 | + expect(message).toContain("protocol: 'datasource:'"); |
| 193 | + expect(message).toContain('capabilities.write: true'); |
| 194 | + // The consequence: the delete is announced but never lands. |
| 195 | + expect(message).toContain('`unregister()`'); |
| 196 | + expect(message).toContain('`deleted`'); |
| 197 | + // Repair A — implement it. Repair B — stop declaring the capability. |
| 198 | + expect(message).toContain('delete(type: string, name: string)'); |
| 199 | + expect(message).toContain('capabilities.write: false'); |
| 200 | + }); |
| 201 | + |
| 202 | + it('the same loader WITH `delete` registers, and `unregister()` really calls it', async () => { |
| 203 | + const deletable = makeLoader({ |
| 204 | + name: 'writable_store', |
| 205 | + protocol: 'datasource:', |
| 206 | + write: true, |
| 207 | + withDelete: true, |
| 208 | + }); |
| 209 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [deletable] }); |
| 210 | + |
| 211 | + expect(registeredLoaderNames(mgr)).toContain('writable_store'); |
| 212 | + |
| 213 | + await mgr.register('object', 'account', { name: 'account' }); |
| 214 | + expect(deletable.saveCalls).toEqual([['object', 'account']]); |
| 215 | + |
| 216 | + await mgr.unregister('object', 'account'); |
| 217 | + expect(deletable.deleteCalls).toEqual([['object', 'account']]); |
| 218 | + // The announced deletion is now the truth in every store. |
| 219 | + expect(await mgr.get('object', 'account')).toBeUndefined(); |
| 220 | + expect(await deletable.exists('object', 'account')).toBe(false); |
| 221 | + }); |
| 222 | +}); |
| 223 | + |
| 224 | +describe('the gate covers exactly the combination `unregister()` acts on', () => { |
| 225 | + it('a read-only `datasource:` loader needs no `delete` — nothing ever writes to it', async () => { |
| 226 | + const readOnly = makeLoader({ |
| 227 | + name: 'reporting_replica', |
| 228 | + protocol: 'datasource:', |
| 229 | + write: false, |
| 230 | + withDelete: false, |
| 231 | + }); |
| 232 | + |
| 233 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [readOnly] }); |
| 234 | + expect(registeredLoaderNames(mgr)).toContain('reporting_replica'); |
| 235 | + |
| 236 | + await mgr.register('object', 'account', { name: 'account' }); |
| 237 | + expect(readOnly.saveCalls).toEqual([]); |
| 238 | + await expect(mgr.unregister('object', 'account')).resolves.toBeUndefined(); |
| 239 | + }); |
| 240 | + |
| 241 | + it.each<Protocol>(['file:', 'memory:', 'http:', 's3:'])( |
| 242 | + 'a `%s` loader may declare write without a `delete` — the manager never persists there', |
| 243 | + (protocol) => { |
| 244 | + const loader = makeLoader({ |
| 245 | + name: `loader_${protocol.replace(':', '')}`, |
| 246 | + protocol, |
| 247 | + write: true, |
| 248 | + withDelete: false, |
| 249 | + }); |
| 250 | + |
| 251 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 252 | + expect(() => mgr.registerLoader(loader)).not.toThrow(); |
| 253 | + expect(registeredLoaderNames(mgr)).toContain(loader.contract.name); |
| 254 | + }, |
| 255 | + ); |
| 256 | +}); |
| 257 | + |
| 258 | +describe('regression — the real `datasource:` loader is unaffected', () => { |
| 259 | + /** |
| 260 | + * `DatabaseLoader` declares `datasource:` + `capabilities.write` and has |
| 261 | + * implemented `delete()` all along; the gate must be a no-op for it. The |
| 262 | + * driver is a stub because registration touches no storage — construction |
| 263 | + * and the contract are the whole surface under test here. |
| 264 | + */ |
| 265 | + it('DatabaseLoader registers under the gate', () => { |
| 266 | + const loader = new DatabaseLoader({ driver: {} as IDataDriver }); |
| 267 | + |
| 268 | + expect(loader.contract.protocol).toBe('datasource:'); |
| 269 | + expect(loader.contract.capabilities.write).toBe(true); |
| 270 | + expect(typeof loader.delete).toBe('function'); |
| 271 | + |
| 272 | + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); |
| 273 | + expect(() => mgr.registerLoader(loader)).not.toThrow(); |
| 274 | + expect(registeredLoaderNames(mgr)).toContain('database'); |
| 275 | + }); |
| 276 | +}); |
0 commit comments