|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#12027] The artifact-vs-DB collision warning fires in BOTH registration |
| 5 | + * orders — including the one a cold boot actually produces. |
| 6 | + * |
| 7 | + * ## What was wrong, and why "the warning is missing" is the wrong description |
| 8 | + * |
| 9 | + * The warning existed and worked. It was guarded on `packageId &&`, so it |
| 10 | + * spoke only when the PACKAGE registered second. A kernel boot cannot produce |
| 11 | + * that order: |
| 12 | + * |
| 13 | + * ``` |
| 14 | + * Phase 1 init AppPlugin.init -> manifest.register -> ObjectQL.registerApp |
| 15 | + * -> registerItem(type, item, 'name', packageId) // pkg:name |
| 16 | + * Phase 2 start ObjectQLPlugin.start -> restoreMetadataFromDb |
| 17 | + * -> protocol.loadMetaFromDb -> hydrateOverlayIntoRegistry |
| 18 | + * -> registerItem(type, item, 'name') // bare name |
| 19 | + * ``` |
| 20 | + * |
| 21 | + * The kernel runs init-all THEN start-all, so the artifact is ALWAYS the first |
| 22 | + * arrival at boot and the overlay always the second — the exact order the |
| 23 | + * `packageId &&` half excluded. Measured on a real `@objectstack/example-crm` |
| 24 | + * boot: one stored `view` overlay of a packaged view produced **0** collision |
| 25 | + * lines and 4 silent shadowings (the container plus its three expanded |
| 26 | + * ViewItems). So a reader who had ever SEEN the warning fire (a marketplace |
| 27 | + * install, an HMR reload — the late-registration order) had every reason to |
| 28 | + * believe the mechanism was sound, while the case it missed was the one that |
| 29 | + * happens on every boot. |
| 30 | + * |
| 31 | + * That is why the first case below is the load-bearing one: a pin written only |
| 32 | + * for the direction that already warned would pass on `origin/main` and prove |
| 33 | + * nothing. |
| 34 | + * |
| 35 | + * ## Two messages, not one widened message |
| 36 | + * |
| 37 | + * Both orders end in the same state — `getItem` checks the bare key first, so |
| 38 | + * the runtime row wins either way (pinned at the bottom of this file, because |
| 39 | + * a diagnostic repair must not move precedence). What differs is the EVENT, |
| 40 | + * and the event is what an operator acts on: a package that is dead on arrival |
| 41 | + * behind a row that predates it, versus a stored row taking over a definition |
| 42 | + * this process just loaded from code. `distinguishable messages` pins that a |
| 43 | + * later "one message fits both" simplification cannot silently drop it. |
| 44 | + * |
| 45 | + * ## The narrowing cases are not decoration |
| 46 | + * |
| 47 | + * A warning that fires on every boot of a normal deployment says nothing (the |
| 48 | + * #12015 ruling, one warning over). Three of the cases below are the volume |
| 49 | + * bound: no packaged item means no line at all, a re-registration of the same |
| 50 | + * overlay is silent (the line marks the transition, not the state — otherwise |
| 51 | + * the read-side hydration would warn once per GET), and a composite entry that |
| 52 | + * is itself an overlay or a tenant-authored body is not a packaged definition |
| 53 | + * being shadowed. |
| 54 | + */ |
| 55 | + |
| 56 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| 57 | +import { SchemaRegistry } from './registry'; |
| 58 | + |
| 59 | +const PKG = 'com.acme.crm'; |
| 60 | + |
| 61 | +/** Every `[Registry] Collision` line emitted while `fn` runs. */ |
| 62 | +function collisionsDuring(fn: () => void): string[] { |
| 63 | + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 64 | + try { |
| 65 | + fn(); |
| 66 | + return spy.mock.calls |
| 67 | + .map((args) => args.map((a) => String(a)).join(' ')) |
| 68 | + .filter((line) => line.includes('[Registry] Collision')); |
| 69 | + } finally { |
| 70 | + spy.mockRestore(); |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +describe('[#12027] SchemaRegistry collision warning is order-symmetric', () => { |
| 75 | + let registry: SchemaRegistry; |
| 76 | + |
| 77 | + beforeEach(() => { |
| 78 | + registry = new SchemaRegistry({ multiTenant: false }); |
| 79 | + registry.logLevel = 'silent'; |
| 80 | + }); |
| 81 | + afterEach(() => { |
| 82 | + vi.restoreAllMocks(); |
| 83 | + }); |
| 84 | + |
| 85 | + it('COMMON COLD-BOOT ORDER — artifact first, then the sys_metadata row: warns', () => { |
| 86 | + // The case that was silent on `origin/main`. Phase 1 registers the packaged |
| 87 | + // flow under `pkg:name`; Phase 2 hydrates the stored row under the bare name. |
| 88 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'packaged' }, 'name', PKG); |
| 89 | + |
| 90 | + const lines = collisionsDuring(() => { |
| 91 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 92 | + }); |
| 93 | + |
| 94 | + expect(lines).toHaveLength(1); |
| 95 | + // The line has to carry the three things an operator needs: which item, |
| 96 | + // which package lost, and what now serves. |
| 97 | + expect(lines[0]).toContain('flow/nightly_sync'); |
| 98 | + expect(lines[0]).toContain(PKG); |
| 99 | + expect(lines[0]).toContain('shadows the package value'); |
| 100 | + }); |
| 101 | + |
| 102 | + it('LATE-REGISTRATION ORDER — sys_metadata row first, then the package: still warns', () => { |
| 103 | + // Unchanged behaviour, pinned so the repair cannot trade one order for the |
| 104 | + // other. This order is a marketplace install / HMR reload, not a boot. |
| 105 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 106 | + |
| 107 | + const lines = collisionsDuring(() => { |
| 108 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'packaged' }, 'name', PKG); |
| 109 | + }); |
| 110 | + |
| 111 | + expect(lines).toHaveLength(1); |
| 112 | + expect(lines[0]).toContain('flow/nightly_sync'); |
| 113 | + expect(lines[0]).toContain('will shadow the package value'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('the two orders produce DISTINGUISHABLE messages', () => { |
| 117 | + // Same end state, different event. A reader must be able to tell "your new |
| 118 | + // package is dead on arrival" from "a stored row just took over"; a single |
| 119 | + // message widened to fit both would have to drop which one arrived second. |
| 120 | + const bootOrder = collisionsDuring(() => { |
| 121 | + registry.registerItem('page', { name: 'home', label: 'packaged' }, 'name', PKG); |
| 122 | + registry.registerItem('page', { name: 'home', label: 'runtime' }, 'name'); |
| 123 | + }); |
| 124 | + const lateOrder = collisionsDuring(() => { |
| 125 | + registry.registerItem('doc', { name: 'home', label: 'runtime' }, 'name'); |
| 126 | + registry.registerItem('doc', { name: 'home', label: 'packaged' }, 'name', PKG); |
| 127 | + }); |
| 128 | + |
| 129 | + expect(bootOrder).toHaveLength(1); |
| 130 | + expect(lateOrder).toHaveLength(1); |
| 131 | + expect(bootOrder[0]).not.toEqual(lateOrder[0]); |
| 132 | + // The tense is the discriminator, and it is the accurate part: one has |
| 133 | + // already happened, the other is what the arriving package is walking into. |
| 134 | + expect(bootOrder[0]).toContain('has just been registered from sys_metadata'); |
| 135 | + expect(lateOrder[0]).toContain('already'); |
| 136 | + }); |
| 137 | + |
| 138 | + it('a discriminated bundle member is judged against its OWN member key', () => { |
| 139 | + // [#7730] `email_template` is keyed by (name, locale). The overlay slot the |
| 140 | + // warning asks about is the member with the SAME discriminator, so the |
| 141 | + // packaged `zh-CN` member and the stored `zh-CN` row collide. |
| 142 | + registry.registerItem( |
| 143 | + 'email_template', |
| 144 | + { name: 'welcome', locale: 'zh-CN', subject: 'packaged' }, |
| 145 | + 'name', |
| 146 | + PKG, |
| 147 | + ); |
| 148 | + |
| 149 | + const lines = collisionsDuring(() => { |
| 150 | + registry.registerItem( |
| 151 | + 'email_template', |
| 152 | + { name: 'welcome', locale: 'zh-CN', subject: 'runtime' }, |
| 153 | + 'name', |
| 154 | + ); |
| 155 | + }); |
| 156 | + |
| 157 | + expect(lines).toHaveLength(1); |
| 158 | + expect(lines[0]).toContain('email_template/welcome'); |
| 159 | + }); |
| 160 | + |
| 161 | + describe('what is NOT a collision — the volume bound', () => { |
| 162 | + it('a runtime row with no packaged counterpart is silent', () => { |
| 163 | + const lines = collisionsDuring(() => { |
| 164 | + registry.registerItem('flow', { name: 'tenant_only', label: 'runtime' }, 'name'); |
| 165 | + }); |
| 166 | + expect(lines).toEqual([]); |
| 167 | + }); |
| 168 | + |
| 169 | + it('re-registering the SAME overlay warns once, not once per registration', () => { |
| 170 | + // The read-side hydration (`getMetaItems`) and the write-through both |
| 171 | + // re-register an overlay that is already in the bare slot. Warning on the |
| 172 | + // STATE rather than the transition would put a line in the log on every |
| 173 | + // GET of a customized item. |
| 174 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'packaged' }, 'name', PKG); |
| 175 | + |
| 176 | + const first = collisionsDuring(() => { |
| 177 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 178 | + }); |
| 179 | + const repeats = collisionsDuring(() => { |
| 180 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime v2' }, 'name'); |
| 181 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime v3' }, 'name'); |
| 182 | + }); |
| 183 | + |
| 184 | + expect(first).toHaveLength(1); |
| 185 | + expect(repeats).toEqual([]); |
| 186 | + }); |
| 187 | + |
| 188 | + it('a composite entry carrying the sys_metadata sentinel is not a packaged definition', () => { |
| 189 | + // `_packageId: 'sys_metadata'` marks an overlay bound to no package |
| 190 | + // (#4636). Nothing shipped from code here, so nothing is being shadowed. |
| 191 | + registry.registerItem( |
| 192 | + 'flow', |
| 193 | + { name: 'nightly_sync', _packageId: 'sys_metadata' }, |
| 194 | + 'name', |
| 195 | + 'sys_metadata', |
| 196 | + ); |
| 197 | + const lines = collisionsDuring(() => { |
| 198 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 199 | + }); |
| 200 | + expect(lines).toEqual([]); |
| 201 | + }); |
| 202 | + |
| 203 | + it('a tenant-authored composite entry is not a packaged definition', () => { |
| 204 | + // ADR-0010 `_provenance: 'org'` — a tenant's own item that came back from |
| 205 | + // a kernel rebuild keyed by a package id (cloud#970). `isCodeArtifactBody` |
| 206 | + // is the single answer to "does a code package ship this?", and this is |
| 207 | + // not it. |
| 208 | + registry.registerItem( |
| 209 | + 'flow', |
| 210 | + { name: 'nightly_sync', _packageId: PKG, _provenance: 'org' }, |
| 211 | + 'name', |
| 212 | + PKG, |
| 213 | + ); |
| 214 | + const lines = collisionsDuring(() => { |
| 215 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 216 | + }); |
| 217 | + expect(lines).toEqual([]); |
| 218 | + }); |
| 219 | + |
| 220 | + it('a package re-registering its own item is silent in both directions', () => { |
| 221 | + const lines = collisionsDuring(() => { |
| 222 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'v1' }, 'name', PKG); |
| 223 | + registry.registerItem('flow', { name: 'nightly_sync', label: 'v2' }, 'name', PKG); |
| 224 | + }); |
| 225 | + expect(lines).toEqual([]); |
| 226 | + }); |
| 227 | + |
| 228 | + it('two packages shipping the same bare name is coexistence, not shadowing', () => { |
| 229 | + // ADR-0048 §3.4 — distinct composite keys, package-scoped resolution. |
| 230 | + // Neither registration takes the bare slot, so this guard never speaks. |
| 231 | + const lines = collisionsDuring(() => { |
| 232 | + registry.registerItem('page', { name: 'home', label: 'CRM' }, 'name', PKG); |
| 233 | + registry.registerItem('page', { name: 'home', label: 'HR' }, 'name', 'com.acme.hr'); |
| 234 | + }); |
| 235 | + expect(lines).toEqual([]); |
| 236 | + }); |
| 237 | + }); |
| 238 | + |
| 239 | + describe('the diagnostic repair moves nothing', () => { |
| 240 | + it('the runtime row still wins in BOTH orders (ADR-0005 overlay precedence)', () => { |
| 241 | + // Clause ② in test form: this card adds a line to a path that printed |
| 242 | + // nothing. Which definition wins is untouched, and untouched IN BOTH |
| 243 | + // ORDERS — a warning that changed precedence would be a different card. |
| 244 | + const bootOrder = new SchemaRegistry({ multiTenant: false }); |
| 245 | + bootOrder.logLevel = 'silent'; |
| 246 | + collisionsDuring(() => { |
| 247 | + bootOrder.registerItem('flow', { name: 'nightly_sync', label: 'packaged' }, 'name', PKG); |
| 248 | + bootOrder.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 249 | + }); |
| 250 | + expect(bootOrder.getItem<any>('flow', 'nightly_sync')?.label).toBe('runtime'); |
| 251 | + expect(bootOrder.getItem<any>('flow', 'nightly_sync', PKG)?.label).toBe('runtime'); |
| 252 | + // …and the packaged definition is still reachable as an artifact. |
| 253 | + expect(bootOrder.getArtifactItem<any>('flow', 'nightly_sync', PKG)?.label).toBe('packaged'); |
| 254 | + |
| 255 | + const lateOrder = new SchemaRegistry({ multiTenant: false }); |
| 256 | + lateOrder.logLevel = 'silent'; |
| 257 | + collisionsDuring(() => { |
| 258 | + lateOrder.registerItem('flow', { name: 'nightly_sync', label: 'runtime' }, 'name'); |
| 259 | + lateOrder.registerItem('flow', { name: 'nightly_sync', label: 'packaged' }, 'name', PKG); |
| 260 | + }); |
| 261 | + expect(lateOrder.getItem<any>('flow', 'nightly_sync')?.label).toBe('runtime'); |
| 262 | + }); |
| 263 | + }); |
| 264 | +}); |
0 commit comments