|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #23690, ADR-0070 D5 / ADR-0048 — an orphan adoption never makes a DRAFT view |
| 5 | + * live: an adopted draft view is rebound in storage only, as the boot treats it. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans` |
| 10 | + * calls) sends each rebound view row through the registry write-through |
| 11 | + * (#23198). The view limb admitted a row by its type alone, so a package-less |
| 12 | + * draft view adopted into Q was registered as live (its draft body, stamped |
| 13 | + * `_packageId` Q) until the next boot, which registers active rows only |
| 14 | + * (`loadMetaFromDb`). `applyRegistryWriteThrough`'s own header: drafts "are a |
| 15 | + * staging buffer and must never leak into the runtime". Measured on |
| 16 | + * `origin/main` `4c21e57169` before the fix. |
| 17 | + * |
| 18 | + * ## The pins, on a real kernel (another kernel over the same store is a boot) |
| 19 | + * |
| 20 | + * - an adopted draft view: before and after the adoption the registry serves |
| 21 | + * nothing for it, the stored row is Q's draft, and a boot afterwards agrees; |
| 22 | + * - an adopted draft beside an adopted active row of the same view: the |
| 23 | + * active body is served at Q, never the draft's; |
| 24 | + * - CONTROL: an adopted active view is served at Q at once (#23198), and a |
| 25 | + * boot afterwards agrees. |
| 26 | + * |
| 27 | + * The negative control (the gate removed, the draft pins red) is a one-off |
| 28 | + * ablation recorded in the PR body, not a test. |
| 29 | + */ |
| 30 | + |
| 31 | +import { randomUUID } from 'node:crypto'; |
| 32 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 33 | +import { ObjectKernel } from '@objectstack/core'; |
| 34 | +import { ObjectQL } from './engine.js'; |
| 35 | +import { ObjectQLPlugin } from './plugin.js'; |
| 36 | + |
| 37 | +type Stores = Map<string, Map<string, Record<string, unknown>>>; |
| 38 | + |
| 39 | +/** |
| 40 | + * In-memory driver, copied from `protocol-adopt-orphans-object-registry.test.ts`; |
| 41 | + * equality-only WHERE. `dropTable` records the tables a run drops, so a pin can |
| 42 | + * say none was. |
| 43 | + */ |
| 44 | +function makeMemoryDriver(stores: Stores, drops: string[]) { |
| 45 | + const storeFor = (obj: string) => { |
| 46 | + let s = stores.get(obj); |
| 47 | + if (!s) { s = new Map(); stores.set(obj, s); } |
| 48 | + return s; |
| 49 | + }; |
| 50 | + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real |
| 51 | + // driver ANDs them. |
| 52 | + const matchesWhere = (row: Record<string, unknown>, where: any): boolean => { |
| 53 | + if (!where || typeof where !== 'object') return true; |
| 54 | + for (const [k, v] of Object.entries(where)) { |
| 55 | + if (k === '$and' && Array.isArray(v)) { |
| 56 | + if (!v.every((w: any) => matchesWhere(row, w))) return false; |
| 57 | + continue; |
| 58 | + } |
| 59 | + if (k === '$or' && Array.isArray(v)) { |
| 60 | + if (!v.some((w: any) => matchesWhere(row, w))) return false; |
| 61 | + continue; |
| 62 | + } |
| 63 | + if (k.startsWith('$')) continue; |
| 64 | + const rowVal = row[k]; |
| 65 | + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; |
| 66 | + const a = rowVal === undefined ? null : rowVal; |
| 67 | + const b = expected === undefined ? null : expected; |
| 68 | + if (a !== b) return false; |
| 69 | + } |
| 70 | + return true; |
| 71 | + }; |
| 72 | + const driver: any = { |
| 73 | + name: 'memory', version: '0.0.0', supports: {} as any, |
| 74 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 75 | + async execute() { return null; }, |
| 76 | + async find(object: string, ast: any) { |
| 77 | + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); |
| 78 | + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). |
| 79 | + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; |
| 80 | + }, |
| 81 | + async findOne(object: string, ast: any) { |
| 82 | + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; |
| 83 | + return null; |
| 84 | + }, |
| 85 | + async create(object: string, data: Record<string, unknown>) { |
| 86 | + const id = (data.id as string) ?? `r_${randomUUID()}`; |
| 87 | + const row = { ...data, id }; |
| 88 | + storeFor(object).set(id, row); |
| 89 | + return row; |
| 90 | + }, |
| 91 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 92 | + const s = storeFor(object); |
| 93 | + const cur = s.get(id); |
| 94 | + if (!cur) throw new Error(`not found: ${object}/${id}`); |
| 95 | + const updated = { ...cur, ...data, id }; |
| 96 | + s.set(id, updated); |
| 97 | + return updated; |
| 98 | + }, |
| 99 | + async upsert(object: string, data: Record<string, unknown>) { |
| 100 | + const id = data.id as string | undefined; |
| 101 | + if (id && storeFor(object).has(id)) return this.update(object, id, data); |
| 102 | + return this.create(object, data); |
| 103 | + }, |
| 104 | + async delete(object: string, id: string) { return storeFor(object).delete(id); }, |
| 105 | + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, |
| 106 | + async bulkCreate(object: string, rows: Record<string, unknown>[]) { |
| 107 | + return Promise.all(rows.map((r) => this.create(object, r))); |
| 108 | + }, |
| 109 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 110 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 111 | + async commit() {}, async rollback() {}, |
| 112 | + async dropTable(object: string) { drops.push(object); stores.delete(object); }, |
| 113 | + }; |
| 114 | + return driver; |
| 115 | +} |
| 116 | + |
| 117 | +/** The data plane read, as the system reads it: typed, never erased (`check:query-options-erasure`). */ |
| 118 | + |
| 119 | +const Q = 'com.qa.q'; |
| 120 | +const VIEW = 'qa_probe_gadget_grid'; |
| 121 | + |
| 122 | +/** A view body; `label` tells the rows apart. */ |
| 123 | +const viewBody = (label: string) => ({ |
| 124 | + name: VIEW, label, type: 'grid', data: { provider: 'object', object: 'qa_probe_gadget' }, |
| 125 | +}); |
| 126 | + |
| 127 | +interface Booted { |
| 128 | + kernel: ObjectKernel; |
| 129 | + engine: ObjectQL; |
| 130 | + protocol: any; |
| 131 | +} |
| 132 | + |
| 133 | +describe('#23690 — an orphan adoption rebinds a draft view in storage only', () => { |
| 134 | + const live: ObjectKernel[] = []; |
| 135 | + afterEach(async () => { |
| 136 | + vi.restoreAllMocks(); |
| 137 | + while (live.length) { |
| 138 | + const kernel = live.pop()!; |
| 139 | + if (kernel.getState() === 'running') await kernel.shutdown(); |
| 140 | + } |
| 141 | + }); |
| 142 | + |
| 143 | + /** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */ |
| 144 | + async function boot(stores: Stores, drops: string[]): Promise<Booted> { |
| 145 | + // gracefulShutdown:false keeps process signal handlers out of vitest's fork workers (#4250). |
| 146 | + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); |
| 147 | + live.push(kernel); |
| 148 | + const engine = new ObjectQL(); |
| 149 | + engine.registerDriver(makeMemoryDriver(stores, drops), true); |
| 150 | + await kernel.use(new ObjectQLPlugin({ ql: engine })); |
| 151 | + await kernel.bootstrap(); |
| 152 | + return { kernel, engine, protocol: kernel.getService('protocol') }; |
| 153 | + } |
| 154 | + |
| 155 | + /** A package-less view row, as a pre-package-first stopgap left it. */ |
| 156 | + const insertOrphan = (k: Booted, state: 'active' | 'draft', label: string) => |
| 157 | + k.engine.insert('sys_metadata', { |
| 158 | + type: 'view', name: VIEW, state, package_id: null, metadata: JSON.stringify(viewBody(label)), |
| 159 | + }, { context: { isSystem: true } } as any); |
| 160 | + |
| 161 | + /** The view's stored rows, as the driver holds them. */ |
| 162 | + const storedRows = (stores: Stores) => |
| 163 | + Array.from(stores.get('sys_metadata')?.values() ?? []) |
| 164 | + .filter((r) => r.type === 'view' && r.name === VIEW) |
| 165 | + .map((r) => ({ state: r.state, packageId: r.package_id ?? null })); |
| 166 | + |
| 167 | + /** What the registry serves for the view: nothing, or its label and package stamp. */ |
| 168 | + const served = (k: Booted) => { |
| 169 | + const view = k.engine.registry.getItem('view', VIEW) as { label?: string; _packageId?: string } | undefined; |
| 170 | + return view === undefined ? undefined : { label: view.label, packageId: view._packageId }; |
| 171 | + }; |
| 172 | + |
| 173 | + it('an adopted DRAFT view: the registry serves nothing before, after, and at the next boot', async () => { |
| 174 | + const stores: Stores = new Map(); |
| 175 | + const drops: string[] = []; |
| 176 | + const k = await boot(stores, drops); |
| 177 | + await insertOrphan(k, 'draft', 'Draft gadgets'); |
| 178 | + expect(served(k)).toBeUndefined(); |
| 179 | + const registerItem = vi.spyOn(k.engine.registry, 'registerItem'); |
| 180 | + |
| 181 | + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); |
| 182 | + |
| 183 | + // The storage rebind and its answer are unchanged. |
| 184 | + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] }); |
| 185 | + expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]); |
| 186 | + // Nothing of the draft reaches the registry. |
| 187 | + expect(registerItem.mock.calls.filter(([type]) => type === 'view')).toEqual([]); |
| 188 | + expect(served(k)).toBeUndefined(); |
| 189 | + |
| 190 | + // A boot afterwards agrees. |
| 191 | + await k.kernel.shutdown(); |
| 192 | + const k2 = await boot(stores, drops); |
| 193 | + expect(served(k2)).toBeUndefined(); |
| 194 | + expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]); |
| 195 | + expect(drops).toEqual([]); |
| 196 | + }); |
| 197 | + |
| 198 | + it('an adopted draft beside an adopted active row of the same view: the active body is served at Q, never the draft', async () => { |
| 199 | + const stores: Stores = new Map(); |
| 200 | + const drops: string[] = []; |
| 201 | + const k = await boot(stores, drops); |
| 202 | + await insertOrphan(k, 'active', 'Published gadgets'); |
| 203 | + await insertOrphan(k, 'draft', 'Draft gadgets'); |
| 204 | + |
| 205 | + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); |
| 206 | + |
| 207 | + expect(adopted).toMatchObject({ success: true, reassignedCount: 2 }); |
| 208 | + expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }, { state: 'draft', packageId: Q }]); |
| 209 | + expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q }); |
| 210 | + |
| 211 | + await k.kernel.shutdown(); |
| 212 | + const k2 = await boot(stores, drops); |
| 213 | + expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q }); |
| 214 | + }); |
| 215 | + |
| 216 | + it('CONTROL: an adopted ACTIVE view is served at Q at once (#23198), and a boot afterwards agrees', async () => { |
| 217 | + const stores: Stores = new Map(); |
| 218 | + const drops: string[] = []; |
| 219 | + const k = await boot(stores, drops); |
| 220 | + await insertOrphan(k, 'active', 'Published gadgets'); |
| 221 | + |
| 222 | + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); |
| 223 | + |
| 224 | + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] }); |
| 225 | + expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }]); |
| 226 | + expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q }); |
| 227 | + |
| 228 | + await k.kernel.shutdown(); |
| 229 | + const k2 = await boot(stores, drops); |
| 230 | + expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q }); |
| 231 | + expect(drops).toEqual([]); |
| 232 | + }); |
| 233 | +}); |
0 commit comments