|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#28337, ADR-0048] `packagelessLayeredReadRefusal`: the layered view's door |
| 5 | + * refusal for a name two or more packages hold, asked with no package. It is |
| 6 | + * the refusal the package-less save and delete answer for the same rows |
| 7 | + * (`packagelessAddressAmbiguityRefusal`, `409 RESOURCE_CONFLICT`, the holders |
| 8 | + * sorted and the `?package=` prescription), over the same resolution |
| 9 | + * (`packagelessAddress` at `active`). |
| 10 | + * |
| 11 | + * The layered read itself is unchanged: its in-process resolution readers |
| 12 | + * (the security catalog echo, the packaged-permission-set lock gate, |
| 13 | + * `/published`) read a name's layers with no package and keep the answer they |
| 14 | + * had. The door asks the judge (`createMetaLayeredAnswer` in `@objectstack/rest`). |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, expect, it } from 'vitest'; |
| 18 | +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; |
| 19 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 20 | + |
| 21 | +const PKG_A = 'app.alpha_demo'; |
| 22 | +const PKG_B = 'app.beta_demo'; |
| 23 | + |
| 24 | +type Row = Record<string, any>; |
| 25 | + |
| 26 | +const rowKey = (w: Row) => |
| 27 | + [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); |
| 28 | + |
| 29 | +const whereValueMatches = (actual: unknown, want: unknown): boolean => |
| 30 | + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) |
| 31 | + ? (want as { $in: unknown[] }).$in.includes(actual) |
| 32 | + : actual === want; |
| 33 | + |
| 34 | +const matchesWhere = (row: Row, where: Row) => |
| 35 | + Object.entries(where).every(([k, v]) => { |
| 36 | + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); |
| 37 | + if (v === null) return row[k] === null || row[k] === undefined; |
| 38 | + return whereValueMatches(row[k], v); |
| 39 | + }); |
| 40 | + |
| 41 | +/** An in-memory `sys_metadata` behind the REAL protocol, with no registry items. */ |
| 42 | +function makeProtocol() { |
| 43 | + const rows = new Map<string, Row>(); |
| 44 | + let nextId = 0; |
| 45 | + const findRow = (w: Row) => { |
| 46 | + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; |
| 47 | + return null; |
| 48 | + }; |
| 49 | + const registry: any = { |
| 50 | + getItem: () => undefined, |
| 51 | + getArtifactItem: () => undefined, |
| 52 | + listItems: () => [], |
| 53 | + getObject: () => undefined, |
| 54 | + isPackageDisabled: () => false, |
| 55 | + isObjectPackageDisabled: () => false, |
| 56 | + getAllPackages: () => [{ manifest: { id: PKG_A } }, { manifest: { id: PKG_B } }], |
| 57 | + getPackage: (id: string) => ({ manifest: { id } }), |
| 58 | + }; |
| 59 | + const engine: any = { |
| 60 | + registry, |
| 61 | + async findOne(table: string, o: { where: Row }) { |
| 62 | + assertEngineFindOnePredicate(table, o); |
| 63 | + if (table !== 'sys_metadata') return null; |
| 64 | + return findRow(o.where)?.row ?? null; |
| 65 | + }, |
| 66 | + async find(table: string, o: { where: Row; limit?: number }) { |
| 67 | + if (table !== 'sys_metadata') return []; |
| 68 | + const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); |
| 69 | + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; |
| 70 | + }, |
| 71 | + async insert(table: string, data: Row) { |
| 72 | + if (table !== 'sys_metadata') return { id: `rec_${++nextId}` }; |
| 73 | + const row = { id: `r_${++nextId}`, ...data }; |
| 74 | + rows.set(rowKey(data), row); |
| 75 | + return { id: row.id }; |
| 76 | + }, |
| 77 | + async update(table: string, data: Row, o: { where: Row }) { |
| 78 | + assertEngineUpdateDispatch(data, o); |
| 79 | + if (table !== 'sys_metadata') return { id: null }; |
| 80 | + const found = findRow(o.where); |
| 81 | + if (!found) return { id: null }; |
| 82 | + const merged = { ...found.row, ...data }; |
| 83 | + rows.delete(found.key); |
| 84 | + rows.set(rowKey(merged), merged); |
| 85 | + return { id: merged.id }; |
| 86 | + }, |
| 87 | + async delete(table: string, o?: Row) { |
| 88 | + assertEngineDeleteDispatch(o); |
| 89 | + if (table !== 'sys_metadata') return { deleted: 0 }; |
| 90 | + const found = findRow((o?.where ?? {}) as Row); |
| 91 | + if (!found) return { deleted: 0 }; |
| 92 | + rows.delete(found.key); |
| 93 | + return { deleted: 1 }; |
| 94 | + }, |
| 95 | + async syncObjectSchema() { /* no physical storage in this double */ }, |
| 96 | + }; |
| 97 | + return new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); |
| 98 | +} |
| 99 | + |
| 100 | +/** Store one dashboard row per package named (`null`: the package-unbound row). */ |
| 101 | +async function store(protocol: ObjectStackProtocolImplementation, name: string, packages: Array<string | null>) { |
| 102 | + for (const pkg of packages) { |
| 103 | + await protocol.saveMetaItem({ |
| 104 | + type: 'dashboard', name, item: { name, label: `${pkg ?? 'local'} copy`, widgets: [] }, |
| 105 | + ...(pkg ? { packageId: pkg } : {}), |
| 106 | + } as any); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +/** What a rejected promise threw. */ |
| 111 | +const thrown = async (p: Promise<unknown>): Promise<any> => { |
| 112 | + try { |
| 113 | + await p; |
| 114 | + } catch (e) { |
| 115 | + return e; |
| 116 | + } |
| 117 | + throw new Error('expected a refusal; the call resolved'); |
| 118 | +}; |
| 119 | + |
| 120 | +describe('[#28337] packagelessLayeredReadRefusal', () => { |
| 121 | + it('a name two packages hold: the package-less save\'s refusal, worded for the layered read', async () => { |
| 122 | + const protocol = makeProtocol(); |
| 123 | + await store(protocol, 'twin_dash', [PKG_B, PKG_A]); |
| 124 | + const refusal = await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name: 'twin_dash' }); |
| 125 | + const saveRefusal = await thrown(protocol.saveMetaItem({ |
| 126 | + type: 'dashboard', name: 'twin_dash', item: { name: 'twin_dash', label: 'edited', widgets: [] }, |
| 127 | + } as any)); |
| 128 | + expect(refusal).toBeInstanceOf(Error); |
| 129 | + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: saveRefusal.code, status: saveRefusal.status }); |
| 130 | + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 }); |
| 131 | + expect(refusal!.message).toBe( |
| 132 | + 'Name the package this layered read is for, then send it again: on the REST door add ?package= with the ' |
| 133 | + + 'package id, in process pass packageId. Nothing was served. dashboard \'twin_dash\' belongs to 2 packages ' |
| 134 | + + `('${PKG_A}', '${PKG_B}'), and a layered read that names none has no single row to show.`, |
| 135 | + ); |
| 136 | + // The holder list is the save's, sorted. |
| 137 | + expect(saveRefusal.message).toContain(`('${PKG_A}', '${PKG_B}')`); |
| 138 | + // The plural URL spelling is the same item. |
| 139 | + expect((await protocol.packagelessLayeredReadRefusal({ type: 'dashboards', name: 'twin_dash' }))?.message) |
| 140 | + .toBe(refusal!.message); |
| 141 | + }); |
| 142 | + |
| 143 | + it('no refusal where the package-less writes take an address: one package, the package-unbound row beside it, or none', async () => { |
| 144 | + const protocol = makeProtocol(); |
| 145 | + await store(protocol, 'solo_dash', [PKG_A]); |
| 146 | + await store(protocol, 'local_beside', [PKG_A, null]); |
| 147 | + await store(protocol, 'local_dash', [null]); |
| 148 | + for (const name of ['solo_dash', 'local_beside', 'local_dash', 'no_such_dash']) { |
| 149 | + expect(await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name }), name).toBeUndefined(); |
| 150 | + } |
| 151 | + }); |
| 152 | + |
| 153 | + it('the layered read itself still serves its in-process readers a name two packages hold', async () => { |
| 154 | + const protocol = makeProtocol(); |
| 155 | + await store(protocol, 'twin_dash', [PKG_A, PKG_B]); |
| 156 | + const layered = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash' }); |
| 157 | + expect(layered.overlay).toMatchObject({ name: 'twin_dash' }); |
| 158 | + for (const pkg of [PKG_A, PKG_B]) { |
| 159 | + const scoped = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash', packageId: pkg }); |
| 160 | + expect(scoped.overlay).toMatchObject({ name: 'twin_dash', label: `${pkg} copy` }); |
| 161 | + } |
| 162 | + }); |
| 163 | +}); |
0 commit comments