|
26 | 26 | * - a row stored before the member column existed (a second locale's body, |
27 | 27 | * no member) is backfilled at boot, so it is answered as its own member. |
28 | 28 | * |
| 29 | + * [#28576, ADR-0146 D11] A write naming no package onto the package's |
| 30 | + * template is closed, so the administrator writes here go into the base's |
| 31 | + * pair (`inPair`, below), where the member semantics are the same row for |
| 32 | + * row; a package-less row is laid down as a release before the close stored |
| 33 | + * it (`seedPackagelessRow`), served until the ceremony moves it, and its |
| 34 | + * package-less write is refused. |
| 35 | + * |
29 | 36 | * ## The harness |
30 | 37 | * |
31 | 38 | * `environment-overlay-five-types.test.ts`'s: the real `ObjectQL` engine and |
@@ -70,6 +77,8 @@ function mailManifest(): Record<string, unknown> { |
70 | 77 | // package's protection envelope (`_packageId`, `_provenance`), which a |
71 | 78 | // hydrated overlay of them is grafted with. |
72 | 79 | version: '1.0.0', |
| 80 | + // [#28576] A pair is formed from the base's namespace (ADR-0146 D3). |
| 81 | + namespace: 'acmemail', |
73 | 82 | emailTemplates: [ |
74 | 83 | template('en-US', 'PKG en'), template('zh-CN', 'PKG zh'), |
75 | 84 | // [#22877 stage 2] A second template the package ships in `en-US` |
@@ -182,7 +191,51 @@ async function boot( |
182 | 191 | engine, () => new Map(), kernel === 'environment' ? 'env_prod' : undefined, |
183 | 192 | ); |
184 | 193 | const load = await protocol.loadMetaFromDb(); |
185 | | - return { engine, protocol, load }; |
| 194 | + await protocol.ensureCustomizationPackage({ basePackageId: PKG } as any); |
| 195 | + return { engine, protocol: inPair(protocol), raw: protocol, load }; |
| 196 | +} |
| 197 | + |
| 198 | +/** |
| 199 | + * [#28576, ADR-0146 D11] A write naming no package onto the package's |
| 200 | + * template is closed, so every administrator write in this file is the one |
| 201 | + * that stays open: into the base's pair, `com.acme.mail.custom`, declaring |
| 202 | + * what it overrides. This wrapper names the pair on each save, publish and |
| 203 | + * delete that names no package, and adds the declaration to a saved body — |
| 204 | + * the member semantics pinned below are the pair's, row for row, as they were |
| 205 | + * the package-less row's. A write that names a package (the drafts into |
| 206 | + * `PKG` below) is passed through untouched. |
| 207 | + */ |
| 208 | +const PAIR = 'com.acme.mail.custom'; |
| 209 | +const DECLARATION = { package: PKG, basedOn: '1.0.0' }; |
| 210 | +function inPair(protocol: ObjectStackProtocolImplementation): ObjectStackProtocolImplementation { |
| 211 | + const named = (request: any) => request?.packageId !== undefined; |
| 212 | + return new Proxy(protocol, { |
| 213 | + get(target, key, receiver) { |
| 214 | + const value = Reflect.get(target, key, receiver); |
| 215 | + if (key === 'saveMetaItem') { |
| 216 | + return (request: any) => (named(request) ? value.call(target, request) : value.call(target, { |
| 217 | + ...request, packageId: PAIR, item: { ...request.item, overrides: DECLARATION }, |
| 218 | + })); |
| 219 | + } |
| 220 | + if (key === 'deleteMetaItem' || key === 'publishMetaItem') { |
| 221 | + return (request: any) => value.call(target, named(request) ? request : { ...request, packageId: PAIR }); |
| 222 | + } |
| 223 | + return typeof value === 'function' ? value.bind(target) : value; |
| 224 | + }, |
| 225 | + }); |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * [#28576] A package-less row of the package's template as a release before |
| 230 | + * the close stored it, written straight to the overlay store (the write door |
| 231 | + * refuses that write now): such a row is still served by every read until |
| 232 | + * `os migrate packageless-overlays` moves it (ADR-0146 D11). |
| 233 | + */ |
| 234 | +async function seedPackagelessRow(raw: ObjectStackProtocolImplementation, name: string, body: Record<string, unknown>) { |
| 235 | + const repo = (raw as any).getOverlayRepo(); |
| 236 | + await repo.put({ type: 'email_template', name, org: 'env' }, body, { |
| 237 | + parentVersion: null, actor: null, source: 'older-release', intent: 'override-artifact', packageId: null, |
| 238 | + }); |
186 | 239 | } |
187 | 240 |
|
188 | 241 | const overlayRows = (stores: Map<string, Map<string, Record<string, unknown>>>) => |
@@ -308,10 +361,15 @@ describe('two locales of one email template survive a restart (#22877)', () => { |
308 | 361 | expect(await bareNameSubject(second.protocol)).toBe('PKG en'); |
309 | 362 | expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'LEGACY zh' }); |
310 | 363 |
|
311 | | - // That locale's next save updates the backfilled row — no second row. |
312 | | - await second.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }); |
| 364 | + // [#28576, ADR-0146 D11] That locale's next save naming no package is |
| 365 | + // refused, naming the ceremony that moves the row; the backfilled row |
| 366 | + // stays the one served — no second row. |
| 367 | + const refused: any = await second.raw.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }) |
| 368 | + .then(() => undefined, (e: unknown) => e); |
| 369 | + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); |
| 370 | + expect(String(refused?.message)).toContain('os migrate packageless-overlays'); |
313 | 371 | expect(overlayRows(stores).map((r) => r.id)).toEqual(['legacy_zh']); |
314 | | - expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); |
| 372 | + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'LEGACY zh' }); |
315 | 373 | }); |
316 | 374 | }); |
317 | 375 |
|
@@ -704,9 +762,10 @@ async function bootWithOverlays( |
704 | 762 | beforeHydration?: (engine: ObjectQL) => void, |
705 | 763 | ) { |
706 | 764 | const first = await boot(stores); |
| 765 | + // [#28576] Package-less rows a release before the close stored: the |
| 766 | + // hydration of an unmigrated row is what this section pins. |
707 | 767 | for (const locale of ['en-US', 'zh-CN']) { |
708 | | - expect(await first.protocol.saveMetaItem({ type: T, name, item: { ...template(locale, `ADMIN ${name} ${locale}`), name } })) |
709 | | - .toMatchObject({ success: true }); |
| 768 | + await seedPackagelessRow(first.raw, name, { ...template(locale, `ADMIN ${name} ${locale}`), name }); |
710 | 769 | } |
711 | 770 | return boot(stores, 'environment', beforeHydration); |
712 | 771 | } |
@@ -794,32 +853,39 @@ describe('[#23074] the artifact envelope is stamped only on the member the packa |
794 | 853 | * (`restoreArtifactRegistryView`) calls is asked about the hydrated entry, |
795 | 854 | * on a boot of its own. |
796 | 855 | */ |
797 | | - async function resetZh(name: string, verbs: { shadow: boolean; overlayEntry: boolean }, after: string | undefined) { |
| 856 | + async function resetZh(name: string, verbs: { shadow: boolean; overlayEntry: boolean }) { |
798 | 857 | const shadowBoot = (await bootWithOverlays(new Map(), name)).engine; |
799 | 858 | expect(shadowBoot.registry.removeRuntimeShadow(T, name, 'zh-CN'), 'removeRuntimeShadow').toBe(verbs.shadow); |
800 | 859 | const overlayEntryBoot = (await bootWithOverlays(new Map(), name)).engine; |
801 | 860 | expect(overlayEntryBoot.registry.removeOverlayEntry(T, name, 'zh-CN'), 'removeOverlayEntry').toBe(verbs.overlayEntry); |
802 | 861 |
|
| 862 | + // [#28576, ADR-0146 D11] The reset of that unmigrated row naming no |
| 863 | + // package is refused, naming the ceremony that moves it, and the row |
| 864 | + // keeps serving its member — before and after a restart. The registry |
| 865 | + // verbs above are what the ceremony's move leaves to the next boot. |
803 | 866 | const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
804 | | - const { engine, protocol } = await bootWithOverlays(stores, name); |
805 | | - expect(await protocol.deleteMetaItem({ type: T, name, member: 'zh-CN' })).toMatchObject({ success: true }); |
806 | | - expect(engine.registry.getItem<any>(T, name, undefined, 'zh-CN')?.subject).toBe(after); |
| 867 | + const { engine, protocol, raw } = await bootWithOverlays(stores, name); |
| 868 | + const refused: any = await raw.deleteMetaItem({ type: T, name, member: 'zh-CN' }).then(() => undefined, (e: unknown) => e); |
| 869 | + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); |
| 870 | + expect(String(refused?.message)).toContain('os migrate packageless-overlays'); |
| 871 | + const kept = `ADMIN ${name} zh-CN`; |
| 872 | + expect(engine.registry.getItem<any>(T, name, undefined, 'zh-CN')?.subject).toBe(kept); |
807 | 873 | expect(engine.registry.getItem<any>(T, name, undefined, 'en-US')?.subject).toBe(`ADMIN ${name} en-US`); |
808 | | - expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); |
| 874 | + expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(kept); |
809 | 875 | const restarted = await boot(stores); |
810 | | - expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); |
| 876 | + expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(kept); |
811 | 877 | } |
812 | 878 |
|
813 | | - it('the reset (?member=): an unshipped member is retired by the overlay-entry tier, and nothing is left serving it', async () => { |
| 879 | + it('the reset (?member=): an unshipped member is retired by the overlay-entry tier; its package-less reset door is closed', async () => { |
814 | 880 | // No `zh-CN` artifact takes its place, so the shadow heal declines and |
815 | 881 | // the overlay-entry retirement takes the tenant row. |
816 | | - await resetZh(DIGEST, { shadow: false, overlayEntry: true }, undefined); |
| 882 | + await resetZh(DIGEST, { shadow: false, overlayEntry: true }); |
817 | 883 | }); |
818 | 884 |
|
819 | | - it('CONTROL the reset (?member=): a shipped member is un-shadowed onto its artifact', async () => { |
| 885 | + it('CONTROL the reset (?member=): a shipped member is un-shadowed onto its artifact; its package-less reset door is closed', async () => { |
820 | 886 | // The shadow heal lifts the entry onto the `zh-CN` artifact, and the |
821 | 887 | // retirement refuses it as packaged code. |
822 | | - await resetZh(NAME, { shadow: true, overlayEntry: false }, 'PKG zh'); |
| 888 | + await resetZh(NAME, { shadow: true, overlayEntry: false }); |
823 | 889 | }); |
824 | 890 |
|
825 | 891 | it('a registry that answers another member\'s artifact: the graft keeps it off the body', async () => { |
|
0 commit comments