diff --git a/.changeset/24547-active-save-namespace-prefix.md b/.changeset/24547-active-save-namespace-prefix.md new file mode 100644 index 00000000000..32cba5cc3f2 --- /dev/null +++ b/.changeset/24547-active-save-namespace-prefix.md @@ -0,0 +1,28 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the direct metadata save and the per-item publish refuse an object whose name lacks its package's namespace prefix, as the package draft publish already does + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing on the metadata save and publish doors), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`. Two other doors made that very same name `active` under the very same package: a direct save, and the publish of the single draft. That left an object whose own package could never publish a draft of it again, and three doors giving opposite verdicts on one name. + +- FROM: an object made `active` under a package that declares a namespace was accepted with `200` when its name lacked the `${namespace}_` prefix. Two doors did this: + - a publish-mode save: `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it; + - the promotion of a single draft: `POST /api/v1/meta/object/:name/publish?package=:id` (and `publishMetaItem`), including a publish naming no package whose draft the door promotes into such a package. +- TO: both doors answer `422`, code `NAMESPACE_PREFIX`. The sentence is the one the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')." + - The save writes nothing. + - The publish promotes nothing, and the draft stays a draft. +- Fix: name the object `${namespace}_${shortName}` (here `rollup_v1_report`), as `os validate`, `os build` and the draft publish already require. A package's namespace is the one it declares, or the one derived from its id when it was installed without one (`com.v1.rollup` is `rollup`). + +**Unchanged.** + +- A package that declares no namespace, and a save or publish bound to no package, are not judged. +- A draft save (`mode=draft`) stores as before. The door that promotes the draft judges it, now with one sentence on all three doors. +- `sys_*` names are exempt, as everywhere the rule runs. +- Stored rows are not migrated and not refused on read. The next direct save or single-draft publish of a stored object whose name lacks its package's prefix is refused. diff --git a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts new file mode 100644 index 00000000000..d65505f8a66 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +// The producer's OWN write-verb dispatch decisions, so the fake engine below +// cannot accept a call ObjectQL refuses (`check:engine-double-contract`). +// Imported from `@objectstack/metadata-core` and not from +// `@objectstack/objectql`: objectql DEPENDS ON this package. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at ALL THREE + * runtime doors that make an object live under a package: the direct save, + * the package draft publish, and the per-item publish. + * + * ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and + * every object it defines MUST be named `${namespace}_${shortName}`. The + * draft-publish door (`publishPackageDrafts`) refused an unprefixed object + * draft with `NAMESPACE_PREFIX`, while the direct (publish-mode) save door + * stored the very same name `active` under the very same package — an object + * live under a name its own package's drafts could never publish again, and + * two runtime doors giving opposite verdicts on one name. + * + * Measured through the wire before the fix (stock showcase): + * `PUT /api/v1/meta/object/v1_report?package=com.v1.rollup` answered 200 + * `state: "active"`, and the same name's draft was refused at + * `POST /api/v1/packages/com.v1.rollup/publish-drafts` with + * `NAMESPACE_PREFIX`. + * + * What these cases pin: + * - the active door refuses the unprefixed name, with the envelope the door + * gives every other authoring refusal (`code` + `status`), the authored + * prescription the publish door serves for the same name, and nothing + * written; + * - the prefixed name saves; + * - a package that declares no namespace, and a save naming no package, are + * untouched (the rule is skipped without a namespace, as `defineStack` + * skips it); + * - a package-less publish save the door resolves INTO a namespaced package + * is judged as a save naming that package; + * - a draft save is unchanged, and the publish door still refuses its draft — + * with the same sentence the active door now answers; + * - the per-item publish (`POST /meta/object/:name/publish`), which promoted + * that draft `active` as well, refuses it with the same envelope and + * sentence and promotes nothing, while a prefixed draft still publishes. + */ + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +/** Does row `r` satisfy `where` (top-level eq + `$or`)? */ +function matchesMetadataWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false; + continue; + } + // `undefined` = "dimension not constrained"; `null` = "must be NULL". + if (v === undefined) continue; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const op = v as Record; + if ('$in' in op) { + if (!(op.$in as unknown[]).includes((r as any)[k])) return false; + continue; + } + if ('$ne' in op) { + if ((r as any)[k] === op.$ne) return false; + continue; + } + } + if ((r as any)[k] !== v) return false; + } + return true; +} + +const PKG = 'com.v1.rollup'; +const NS = 'rollup'; +/** A package that declares no namespace — legacy-grandfathered. */ +const BARE_PKG = 'com.v1.bare'; + +function makeStubEngine() { + const rows = new Map(); + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const manifests: Record> = { + [PKG]: { id: PKG, namespace: NS }, + [BARE_PKG]: { id: BARE_PKG }, + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {})); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + async insert(table: string, data: Record) { + nextId += 1; + if (table !== 'sys_metadata') return { id: `x_${nextId}` }; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(table: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined), + }, + }; + return { engine, rows }; +} + +const objectBody = (name: string, label = `Object ${name}`) => ({ + name, + label, + // [#8308] Authored OWD — the authoring gate refuses an OWD-less custom object. + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + }, +}); + +/** The stored rows for one object name, as `(state, package_id)` pairs. */ +const rowsFor = (rows: Map, name: string) => + Array.from(rows.values()) + .filter((r) => r.type === 'object' && r.name === name) + .map((r) => ({ state: r.state, package_id: r.package_id })); + +describe('[#24547 · ADR-0129 D2] the active save door refuses an object without its package namespace prefix', () => { + it('refuses an unprefixed object under a namespaced package — NAMESPACE_PREFIX / 422, nothing written', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + const outcome = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + }).then(() => 'saved', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + // The prescription names the rename and the namespace it comes from. + expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /); + expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`); + // Refused before any write: no row, active or draft, under any package. + expect(rowsFor(rows, 'v1_report')).toEqual([]); + }); + + it('saves the prefixed name under the same package', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + item: objectBody(`${NS}_v1_report`), + packageId: PKG, + }); + + expect(rowsFor(rows, `${NS}_v1_report`)).toEqual([{ state: 'active', package_id: PKG }]); + }); + + it('control: a package that declares no namespace saves the unprefixed name', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: BARE_PKG, + }); + + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: BARE_PKG }]); + }); + + it('control: a save naming no package (and resolving to none) saves the unprefixed name', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + }); + + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: null }]); + }); + + it('judges a package-less publish save against the namespaced package it resolves into', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // An active row bound to the namespaced package already holds the + // name (stored before this door read the rule), so a save naming no + // package writes THAT package's row. + await engine.insert('sys_metadata', { + type: 'object', + name: 'v1_report', + organization_id: null, + package_id: PKG, + state: 'active', + metadata: JSON.stringify(objectBody('v1_report')), + checksum: 'sha256:stored', + version: 1, + }); + + const outcome = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report', 'Edited'), + }).then(() => 'saved', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + // The stored row is untouched. + const stored = Array.from(rows.values()).find((r) => r.name === 'v1_report'); + expect(JSON.parse(stored!.metadata).label).toBe('Object v1_report'); + }); + + it('a draft save is unchanged, and the publish door refuses that draft with the sentence the active door answers', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + const activeRefusal = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + }).then(() => undefined, (e: Error) => e); + + // The draft save stores, as before — the door that promotes a draft + // is the gate it answers to. + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + mode: 'draft', + }); + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + + const res = await protocol.publishPackageDrafts({ packageId: PKG } as any); + const itemRefusal = await protocol.publishMetaItem({ + type: 'object', + name: 'v1_report', + packageId: PKG, + }).then(() => undefined, (e: Error) => e); + + expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1 }); + expect(res.failed[0]).toMatchObject({ type: 'object', name: 'v1_report', code: 'NAMESPACE_PREFIX' }); + // ONE rule, ONE sentence: the three doors cannot disagree on the + // verdict or on what the author is told to do about it. + expect(activeRefusal).toBeInstanceOf(Error); + expect(itemRefusal).toBeInstanceOf(Error); + expect(res.failed[0].error).toBe(activeRefusal!.message); + expect(itemRefusal!.message).toBe(activeRefusal!.message); + // Nothing went live. + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + }); + + it('the per-item publish refuses an unprefixed object draft under a namespaced package — NAMESPACE_PREFIX / 422, the draft stays a draft', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + mode: 'draft', + }); + + const outcome = await protocol.publishMetaItem({ + type: 'object', + name: 'v1_report', + packageId: PKG, + }).then(() => 'published', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /); + expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`); + // Nothing promoted: no active row, the draft untouched. + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + }); + + it('control: the per-item publish promotes a prefixed object draft under the same package', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + item: objectBody(`${NS}_v1_report`), + packageId: PKG, + mode: 'draft', + }); + + const res = await protocol.publishMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + packageId: PKG, + }); + + expect(res.success).toBe(true); + expect(rowsFor(rows, `${NS}_v1_report`)).toContainEqual({ state: 'active', package_id: PKG }); + expect(rowsFor(rows, `${NS}_v1_report`)).not.toContainEqual({ state: 'draft', package_id: PKG }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 04317a2aaba..aecdeed0e1a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22894,6 +22894,31 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#24547 · ADR-0129 D2] The package namespace-prefix verdict on one object + * name: `validateObjectNamespacePrefix`'s own authored prescription when + * `name` lacks the `${namespace}_` prefix of the package it is written + * under, else `null`. + * + * ONE judge for the three runtime doors that make an object live under a + * package — the publish pre-flight of {@link publishPackageDrafts}, the + * direct (publish-mode) save of {@link saveMetaItem}, and the per-item + * publish of {@link publishMetaItem} (asked in + * {@link promoteDraftForPublish}, on the package the promotion writes + * under) — so they cannot give opposite verdicts on one name in one + * package again, nor tell the author different things about it. + * + * The namespace is the one the package DECLARES: its installed manifest, + * where {@link installPackage} derives and persists one when the author + * wrote none. A package that declares none, and a write bound to no + * package, are not judged — like `defineStack`, nothing here invents a + * prefix (`validateObjectNamespacePrefix` skips an empty namespace). + */ + private objectNamespacePrefixViolation(name: string, packageId: string | null | undefined): string | null { + if (typeof packageId !== 'string' || packageId === '') return null; + return validateObjectNamespacePrefix(name, this.engine?.registry?.getPackage?.(packageId)?.manifest?.namespace); + } + // [#21207] `parentVersion` is a CALLER's version token — the keyed form a // receipt served — and is compared in that form (`storedParentForToken`). // `storedParentVersion` is the in-process twin for a caller that read the @@ -23347,6 +23372,56 @@ export class ObjectStackProtocolImplementation implements } } + // [#24547 · ADR-0129 D2] THE NAMESPACE-PREFIX RULE, on the door that + // makes an object live directly. Every object a package defines MUST + // be named `${namespace}_${shortName}`; the publish pre-flight + // ({@link publishPackageDrafts}) refused an unprefixed object draft + // with `NAMESPACE_PREFIX`, while this save stored the very same name + // `active` under the very same package — an object live under a name + // its own package's drafts could never publish again. The three doors + // that make an object live under a package — this save, the publish + // pre-flight and the per-item publish ({@link publishMetaItem}, asked + // in {@link promoteDraftForPublish}) — ask one judge + // ({@link objectNamespacePrefixViolation}) and answer its one authored + // prescription. + // + // Judged against the package this save WRITES: the one it names, or + // the one a package-less publish save resolved to above — the save IS + // a save naming that package from there on. After the D9.9 refusal on + // purpose: an object bound to a package that does not own it is told + // which package does, before it is told how to rename it under the + // wrong one. Before every gate that reads or reshapes the body, so a + // refused name costs no parse and writes nothing. + // + // Asked of a publish-mode save only. A draft is not live: whichever + // door promotes it judges it — the publish pre-flight batch-atomically + // and in one report with every other violation, the per-item publish + // before its promotion writes. Not asked of the + // server-stated rewrites of a row this store already holds (`source` + // set: the stored-metadata migration, the stored flow-credential move) + // — they define no object, and refusing one would strand a repair of a + // row stored before this door read the rule. The package copies write + // new rows under their target and are asked like any author: + // `package-duplicate` names every copied object in the target's + // namespace by construction, and `package-unmanaged-import` keeps the + // source's names under the source's namespace, which its plan states + // as the condition this rule admits them on. + // + // `NAMESPACE_PREFIX` / 422: the code the publish pre-flight already + // stamps for this exact violation, registered to this package in the + // ADR-0112 ledger, at the status this method gives every other refusal + // of a body that exists but may not be stored as authored + // (`INVALID_METADATA`, `OBJECT_OVERLAY_PACKAGE_MISMATCH`). + if (singularType === 'object' && mode === 'publish' && request.source === undefined) { + const prefixViolation = this.objectNamespacePrefixViolation(request.name, request.packageId); + if (prefixViolation) { + const err: any = new Error(prefixViolation); + err.code = 'NAMESPACE_PREFIX'; + err.status = 422; + throw err; + } + } + // [ADR-0010 §3.5, Phase 4.3] A `protection` block is refused, on every // type that declares one: only a code package's loader translates it // into a lock, and this door writes environment-authored items, so it @@ -25549,6 +25624,31 @@ export class ObjectStackProtocolImplementation implements ); } } + // [#24547 · ADR-0129 D2] …and the namespace-prefix rule, the third + // door's turn: the per-item publish (`POST /meta/object/:name/publish`) + // made an object `active` under a namespaced package although its name + // lacks the `${namespace}_` prefix — the name the direct save and the + // publish pre-flight refuse. Asked with their judge + // ({@link objectNamespacePrefixViolation}) and their envelope + // (`NAMESPACE_PREFIX` / 422, the judge's sentence), on `promoteInto` — + // the package the active row is written under, as the D9.9 refusal + // above is — and before `repo.promoteDraft` writes, so nothing is + // promoted and the draft stays a draft. A promotion into no package, + // or into a package that declares no namespace, is not judged; nor is + // sealed managed content, for the D9.9 block's reason (its store + // refusal is that row's one answer). `publishPackageDrafts` also + // promotes through here, but its pre-flight has refused the same name + // with the same judge before any promotion runs, so this answers the + // per-item publish alone. + if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { + const prefixViolation = this.objectNamespacePrefixViolation(request.name, promoteInto); + if (prefixViolation) { + const err: any = new Error(prefixViolation); + err.code = 'NAMESPACE_PREFIX'; + err.status = 422; + throw err; + } + } // [#21470] …and the divergent `name` refusal, on the same body and for // the same reason: a draft stored before `saveMetaItem` judged every // type can carry a `name` that is not its row's, and promoting it @@ -26527,7 +26627,11 @@ export class ObjectStackProtocolImplementation implements // packages are grandfathered); the default is derived+persisted once at // install time (`installPackage`), so real Studio packages always have // one by the time they publish. - const pkgNamespace = this.engine?.registry?.getPackage?.(request.packageId)?.manifest?.namespace; + // + // [#24547] The verdict is {@link objectNamespacePrefixViolation}'s, the + // judge the direct save in {@link saveMetaItem} and the per-item + // publish ({@link promoteDraftForPublish}) ask too, so the three doors + // give one verdict and one sentence for one name in one package. /** * Every PRE-FLIGHT refusal of this batch, in one report. * @@ -26551,18 +26655,16 @@ export class ObjectStackProtocolImplementation implements error: string; code: PreflightViolationCode; }> = []; - if (pkgNamespace) { - for (const d of drafts) { - if (d.type !== 'object') continue; - const err = validateObjectNamespacePrefix(d.name, pkgNamespace); - if (err) { - preflightViolations.push({ - type: d.type, - name: d.name, - error: err, - code: 'NAMESPACE_PREFIX', - }); - } + for (const d of drafts) { + if (d.type !== 'object') continue; + const err = this.objectNamespacePrefixViolation(d.name, request.packageId); + if (err) { + preflightViolations.push({ + type: d.type, + name: d.name, + error: err, + code: 'NAMESPACE_PREFIX', + }); } } @@ -28058,9 +28160,9 @@ export class ObjectStackProtocolImplementation implements // `${namespace}_${name}`". The copy is a NEW object, so that rename // costs nothing here — while renaming the SOURCE object would move its // table. A prefixed name swaps the source prefix for the target's; - // a name WITHOUT the source prefix (a direct-publish save, which no - // prefix gate reads, or a package that had no namespace) takes the - // target prefix in front of its whole name. + // a name WITHOUT the source prefix (a direct-publish save stored + // before that door read the prefix rule — #24547 — or a package that + // had no namespace) takes the target prefix in front of its whole name. // // ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by // the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name diff --git a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts index a631b60143e..9b4cd034afb 100644 --- a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts @@ -232,11 +232,29 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so an object whose name lacks its package's prefix is + * a row stored before it read the rule. Staged while the package's + * installed manifest declares no namespace, then the derived one is put + * back, so every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(packageId: string, type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(packageId)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(packageId, type, item); + } finally { + manifest.namespace = namespace; + } + } + /** Seeds the third package and the source, then duplicates the source. */ async function duplicate(): Promise { await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); await save(R, 'object', accountBody); - await save(R, 'object', extBody); + await saveStoredBeforeRule(R, 'object', extBody); // [#24563] The source DECLARES the third package it references: the save // door judges a package's list view against the package's closure and // refuses a binding outside it (`list-view-object-outside-closure`), so @@ -246,7 +264,7 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app', dependencies: { [R]: '*' } } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); - await save(P, 'object', ticketBody); + await saveStoredBeforeRule(P, 'object', ticketBody); await save(P, 'object', noteBody); await save(P, 'view', viewItem('ticket', 'board', 'list', grid('ticket', ['title']))); await save(P, 'view', viewItem('dupsrc_customer', 'board', 'list', grid('dupsrc_customer', ['full_name', 'dupsrc_customer', 'x_dupsrc_customer']))); diff --git a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts index 60ead6c2647..a1cf3694135 100644 --- a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts @@ -198,6 +198,24 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so a bare name under a namespaced package is a row + * stored before it read the rule. Staged while the package's installed + * manifest declares no namespace, then the derived one is put back, so + * every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(packageId: string, type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(packageId)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(packageId, type, item); + } finally { + manifest.namespace = namespace; + } + } + /** Seeds the source and the third package, duplicates, and returns the copy's `fields`. */ async function duplicate(): Promise> { await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); @@ -205,7 +223,7 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); - await save(P, 'object', ticketBody); + await saveStoredBeforeRule(P, 'object', ticketBody); await save(P, 'permission', flsBody); const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); diff --git a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts index 91303a5fa7d..2c8971e6362 100644 --- a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts @@ -9,7 +9,8 @@ * * The method renamed an object only when its name started with the source * prefix (`dupsrc_customer` → `dupdst_customer`). A name without it (`ticket`, - * stored by a direct-publish save, which no prefix gate reads) kept its name, + * stored by a direct-publish save before that door read the prefix rule — + * #24547 — or under a package that had no namespace) kept its name, * and an object name IS its registry key and its table: the copy was a second * claim on the source's object. It used to store a target row the registry * refused, over the source's table; since the ADR-0029 D9.9 refusal landed it @@ -195,18 +196,38 @@ describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the sou expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so a bare name under a namespaced package is a row + * stored before it read the rule. Staged while the package's installed + * manifest declares no namespace, then the derived one is put back, so + * every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(P)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(type, item); + } finally { + manifest.namespace = namespace; + } + } + async function seedSource(extra: Array<[string, { name: string }]> = []): Promise { await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save('object', customerBody); - await save('object', ticketBody); + await saveStoredBeforeRule('object', ticketBody); await save('object', noteBody); await save('view', ticketViewsBody); await save('permission', opsBody); await save('flow', onTicketBody); for (const [type, item] of extra) await save(type, item); - // CONTROL: the bare name was stored by a direct save, no prefix gate read it. + // CONTROL: the bare name is stored, owned by the source, under the + // source's namespace — the row the copy has to rename. expect(owner('ticket')).toBe(P); + expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); } it('copies a prefixed and a non-prefixed object, each into the target namespace', async () => { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 2f4714f5bec..c7bfaee37fc 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -251,6 +251,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts", "verb": "delete",