Skip to content

Commit 0b4be0b

Browse files
fix(metadata-protocol)!: the direct metadata save refuses an object without its package namespace prefix (#24702)
Fixes #24547 Clause-②: no (narrowing) (the active save door newly refuses an object whose name lacks its package's namespace prefix, which today it makes live; a narrowing owes one `CONTRACT_REVIEW_TIER` review of record before the queue) Ruling-ref: 6278955141 (the maintainer: 「同意」 — pull forward into 18.0 as a refusal). Surface amendment: 6280587888 (the per-item publish door: same rule, same code, same judge). ## What 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 (`publishPackageDrafts`) refused an unprefixed object draft with `NAMESPACE_PREFIX`. The direct (publish-mode) `saveMetaItem` and the per-item publish (`publishMetaItem`) made the very same name `active` under the very same package. Now all three doors ask one judge and give one answer. - `packages/metadata-protocol/src/protocol.ts` - New private `objectNamespacePrefixViolation(name, packageId)`. It reads the package's declared namespace from the installed manifest and returns `validateObjectNamespacePrefix`'s own sentence, or `null`. A package with no namespace, and a write bound to no package, are not judged. - `publishPackageDrafts`' pre-flight now calls that judge. This is a pure refactor: same namespace read, same verdict, same code. - `saveMetaItem` asks the same judge for an `object` in publish mode. It is judged against the package the save writes: the one it names, or the one a package-less publish save resolved into. On a violation it throws `code: 'NAMESPACE_PREFIX'`, `status: 422`, with the verbatim prescription. - Position: after the ADR-0029 D9.9 refusal, and before the protection/lock gates and the body parse. - Not asked of draft saves: the door that promotes the draft judges it, the publish pre-flight batch-atomically and the per-item publish before its promotion writes. - Not asked of the server-stated rewrites of stored rows (`source` set: `migrate-stored`, the flow-credential move). - The package copies (`package-duplicate`, `package-unmanaged-import`) are asked, like any author. - `promoteDraftForPublish`, the per-item publish's promotion, asks the same judge for an `object` draft on `promoteInto`, the package the active row is written under. On a violation it throws the same `NAMESPACE_PREFIX` / 422 with the same sentence, before `repo.promoteDraft` writes, so nothing is promoted and the draft stays a draft. The exemptions are the same: no package, no namespace, `sys_*`, and sealed managed content (as for the D9.9 refusal beside it). `publishPackageDrafts` also promotes through here, but its pre-flight refuses the same name first. - A comment in `duplicatePackage` that called the direct save "a door no prefix gate reads" is corrected. - No new error code. `NAMESPACE_PREFIX` is already registered to `@objectstack/metadata-protocol` in `ERROR_CODE_LEDGER` (`packages/spec/src/api/error-code-ledger.zod.ts`), and the publish pre-flight already stamps it. No ADR edit and no `packages/spec` change. - Changeset: `.changeset/24547-active-save-namespace-prefix.md`. It is `minor` and **BREAKING**, has FROM / TO / Fix lines covering both doors (the direct save and the per-item publish), the `Clause-②: no (narrowing)` line and `adr-0087: not-required (no-migration-prescription)`. `check:adr-0087-registration` accepts that disposition. ## Measured on the wire (fresh showcase on this branch, after the fix: the table at `3b94654c58`; the per-item rows, and a re-read of the direct PUT and publish-drafts with the same answers, at `afb7f17447`) | request | answer | |---|---| | `POST /api/v1/packages` `com.v1.rollup` | 201 (namespace derived: `rollup`) | | `PUT /api/v1/meta/object/v1_report?package=com.v1.rollup` | **422** `{"error":"Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup').","code":"NAMESPACE_PREFIX"}`; `GET /api/v1/meta/object/v1_report` then 404 | | same door, missing `sharingModel` (sibling refusal, for the envelope) | 422 `{"error":"object/rollup_no_owd failed author-time validation: …","code":"INVALID_METADATA","issues":[…]}` | | `PUT …/meta/object/rollup_v1_report?package=com.v1.rollup` (prefixed) | 200 `state: active` | | `PUT …/meta/object/v1_loose` (no package) | 200 `state: active` | | `PUT …/meta/object/v1_report?package=com.v1.rollup&mode=draft` | 200 `state: draft` (unchanged) | | `POST /api/v1/packages/com.v1.rollup/publish-drafts` | 200 `outcome: refused`, `failed[0].code: NAMESPACE_PREFIX`, the same sentence (unchanged) | | `POST /api/v1/meta/object/v1_report/publish?package=com.v1.rollup` (per-item publish of that draft) | **422** `{"error":"Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup').","code":"NAMESPACE_PREFIX"}`. Then `GET /api/v1/meta/object/v1_report` 404 and `POST /api/v1/data/v1_report` 404 `OBJECT_NOT_FOUND`, and a following publish-drafts still lists the draft in `failed[]` | | prefixed draft `rollup_v1_report`, then `POST …/meta/object/rollup_v1_report/publish?package=com.v1.rollup` | 200, then `GET` 200 | The `/meta` PUT door answers in the flat REST dialect, so the code is top-level `code`, beside `error`. That is the same place the door puts `INVALID_METADATA`. ## Pins (`packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts`, 8 cases) 1. An unprefixed object under a namespaced package is refused. The pin asserts `code: NAMESPACE_PREFIX` and `status: 422` (the envelope, not a bare throw), the first sentence, the rename, and that no row is written. 2. The prefixed name saves `active`. 3. Control: a package that declares no namespace saves the unprefixed name. 4. Control: a save naming no package saves the unprefixed name. 5. A package-less publish save that resolves into the namespaced package (its active row is bound there) is refused, and the stored row is untouched. 6. A draft save is unchanged. `publishPackageDrafts` refuses it with `NAMESPACE_PREFIX`, and `publishMetaItem` refuses it too. `failed[0].error` and the per-item refusal's message both equal the active door's message byte for byte, and the draft is still the only row. 7. The per-item publish of an unprefixed object draft under a namespaced package is refused. The pin asserts `code: NAMESPACE_PREFIX` and `status: 422`, the first sentence and the rename. Nothing is promoted and the draft stays a draft. 8. Control: the per-item publish promotes a prefixed object draft under the same package. Reproduced first, on the unfixed code: cases 1, 5 and 6 red (the active save answered `saved`), cases 2–4 green. Ablation (`scripts/ablation-replace.mjs`, on the committed fix): `if (prefixViolation) {` was changed to `if (false && prefixViolation) {`. The anchor went 1 → 0 and the blob 72a5cff → 88f5360fcedf. Cases 1, 5 and 6 went red and 2–4 stayed green. Restore was proven: blob == HEAD and `git diff HEAD` empty. The direction was red, as expected. No dist build was needed: the suite imports `./protocol.js` from source. Second ablation, the per-item publish branch, on the committed fix `afb7f17447`. - Declared direction, before running: red on exactly cases 6 and 7, green on the other six. - `const prefixViolation = this.objectNamespacePrefixViolation(request.name, promoteInto);` was changed to `const prefixViolation = false && this.objectNamespacePrefixViolation(request.name, promoteInto);`. The anchor went 1 → 0 and the blob aecdeed → b1797c55d012. - Result: '2 failed | 6 passed', exactly cases 6 and 7. - Restore was proven: blob == HEAD and `git diff HEAD` empty. ## Verification on the final head `afb7f17447` (the per-item publish fix, on top of a merge of `origin/main` `d1173825b7` through `os-regen-merge.sh`) - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 251 files passed (3 skipped), 20798 tests passed. - objectql, the whole suite on the fixed dist: 449 files and 8452 tests, all passed. In round 0, before the fixture change, its only 14 failures were the three adjusted files, each failing on the new refusal. - `pnpm --filter @objectstack/metadata-protocol typecheck` and `pnpm --filter @objectstack/objectql typecheck`: exit 0 (`check:test-typecheck` OK, ledger unchanged). - Derived gate union (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 78 commands, all exit 0. `--ran` reconciliation: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first answered exit 3 (PREREQUISITE NOT MET: the fresh worktree had no full build yet). Re-run after the build: exit 0. - Dogfood, run once on `3b94654c58` (not re-run in this round), on the files that write objects under a package: `package-first-authoring`, `flow-shipped-name-published-door` and `install-local-unmanaged-mode` all passed. `wall-environment-metadata-refusal` had 2 failures, unrelated to this diff; see Acceptance notes. - Not run locally, left to CI: `test:repo`, which this diff does not touch, and the consumer packages' own suites outside the measured set below. ## File surface beyond the claim (declared) - `packages/objectql/src/protocol-duplicate-package-{unprefixed-object,names-in-step,permission-field-keys}.test.ts`. These ADR-0070 D4 duplicate fixtures staged an unprefixed object under a namespaced package through the direct save. That is the exact shape this change refuses, so without this change `Test Core` goes red. Each file now stages those objects with a `saveStoredBeforeRule` helper, which hides the package's namespace for the one save and then restores it. That models a row stored before the door read the rule, so every case keeps what it pins. No assertion changed. - `scripts/engine-double-contract.pinned.json`: the `check:engine-double-contract --write` remedy for the new suite's pinned double. It adds 3 rows and loses 0. ## Shipped-tree exposure (measured) - Unprefixed objects saved through the direct door under a namespaced package: - `examples/`: 0. Code packages pass `defineStack`, and no test does a package-bound object save. - `packages/metadata-protocol` tests: 0. The full suite is green. - `packages/objectql` tests: 4 staging sites in 3 files, adjusted as above. - Controls: - the runtime integration tests use `com.probe.p` / `com.probe.q`, from which `deriveNamespaceFromPackageId` derives `null` (a single-letter segment); - the dogfood package-bound saves use unregistered bases, or `crm_`-prefixed names. ## Other doors that make an object live (enumerated; line numbers at `3b94654c58`) - `publishMetaItem` (through `promoteDraftForPublish`; `POST /api/v1/meta/:type/:name/publish`) is now judged too, per the seat's surface amendment 6280587888 on the card. At `3b94654c58` it still made the unprefixed object live: 200, then `GET` 200 and a data insert 201. At `afb7f17447` it answers 422 `NAMESPACE_PREFIX` (table above). - Covered through `saveMetaItem`: `duplicatePackage` (`:27916`), `importUnmanagedPackage` (`:28629`), and the datasource external import (`packages/services/service-datasource/src/plugin.ts:206`, which writes no package and runs its own prefix check). - Restore-only doors, which bring back a body that was already live: `revertCommit` (`:29484`), `rollbackToPackageCommit` (`:29975`) and `rollbackMetaItem` (`:30046`). `loadMetaFromDb` (`:31403`) hydrates stored rows at rest. - `installPackage` registers a manifest and makes no object live. ## Acceptance notes - ADR-0129 D2 and the `packages/spec/src/kernel/namespace-prefix.ts` header name `MetadataManager.publishPackage()` as the runtime chokepoint. That method (`packages/metadata/src/metadata-manager.ts:1879`) runs no object-name prefix check. The runtime doors that do are `publishPackageDrafts` and, with this change, `saveMetaItem`. No edit here, because both files are outside this card's surface. Carrier: none. - `importUnmanagedPackage` now meets this refusal for a source package that declares no namespace and whose objects lack the prefix derived from its id. The refusal arrives mid-copy and the existing rollback undoes it, rather than at plan time. Not measured: no fixture has that shape. Carrier: none. - Unrelated dogfood red, reproduced on this head: `packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts`, the "thirteen write doors" case, 2 failures under `isolated` and `group`. Its package publish of a `crm_lead.*` view is refused with `list-view-object-outside-closure`. That is the list-view closure refusal `origin/main` brought in, and nothing in this diff touches a view. The Dogfood Regression Gate does not run on PRs, so this surfaces on `main`'s push battery. ## For the contract-tier reviewer - The `source !== undefined` exemption (server-stated rewrites) is deliberate. The two package-copy faces are deliberately judged. - The refusal reads `request.packageId` after the package-less publish address resolution. Case 5 pins that. - The per-item publish branch sits after the D9.9 refusal on `promoteInto` and before `repo.promoteDraft`, with the same sealed-managed exemption as that refusal. Case 7 and the second ablation pin it. --- _Generated by [Claude Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d20bf9b commit 0b4be0b

7 files changed

Lines changed: 578 additions & 22 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the direct (publish-mode) metadata save door and at the per-item publish door of an object name that the package draft publish pre-flight, `os validate` and `os build` already refuse under ADR-0129 D2: no authorable key, spelling, export, error code or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose name lacks its package's prefix keeps loading and serving; the repair is a new object under the prefixed name, which moves its table and so is the author's decision, not something a ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing on the metadata save and publish doors), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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.
14+
15+
- 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:
16+
- 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;
17+
- 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.
18+
- 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')."
19+
- The save writes nothing.
20+
- The publish promotes nothing, and the draft stays a draft.
21+
- 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`).
22+
23+
**Unchanged.**
24+
25+
- A package that declares no namespace, and a save or publish bound to no package, are not judged.
26+
- A draft save (`mode=draft`) stores as before. The door that promotes the draft judges it, now with one sentence on all three doors.
27+
- `sys_*` names are exempt, as everywhere the rule runs.
28+
- 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.
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
// The producer's OWN write-verb dispatch decisions, so the fake engine below
5+
// cannot accept a call ObjectQL refuses (`check:engine-double-contract`).
6+
// Imported from `@objectstack/metadata-core` and not from
7+
// `@objectstack/objectql`: objectql DEPENDS ON this package.
8+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
9+
import { ObjectStackProtocolImplementation } from './protocol.js';
10+
11+
/**
12+
* [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at ALL THREE
13+
* runtime doors that make an object live under a package: the direct save,
14+
* the package draft publish, and the per-item publish.
15+
*
16+
* ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and
17+
* every object it defines MUST be named `${namespace}_${shortName}`. The
18+
* draft-publish door (`publishPackageDrafts`) refused an unprefixed object
19+
* draft with `NAMESPACE_PREFIX`, while the direct (publish-mode) save door
20+
* stored the very same name `active` under the very same package — an object
21+
* live under a name its own package's drafts could never publish again, and
22+
* two runtime doors giving opposite verdicts on one name.
23+
*
24+
* Measured through the wire before the fix (stock showcase):
25+
* `PUT /api/v1/meta/object/v1_report?package=com.v1.rollup` answered 200
26+
* `state: "active"`, and the same name's draft was refused at
27+
* `POST /api/v1/packages/com.v1.rollup/publish-drafts` with
28+
* `NAMESPACE_PREFIX`.
29+
*
30+
* What these cases pin:
31+
* - the active door refuses the unprefixed name, with the envelope the door
32+
* gives every other authoring refusal (`code` + `status`), the authored
33+
* prescription the publish door serves for the same name, and nothing
34+
* written;
35+
* - the prefixed name saves;
36+
* - a package that declares no namespace, and a save naming no package, are
37+
* untouched (the rule is skipped without a namespace, as `defineStack`
38+
* skips it);
39+
* - a package-less publish save the door resolves INTO a namespaced package
40+
* is judged as a save naming that package;
41+
* - a draft save is unchanged, and the publish door still refuses its draft —
42+
* with the same sentence the active door now answers;
43+
* - the per-item publish (`POST /meta/object/:name/publish`), which promoted
44+
* that draft `active` as well, refuses it with the same envelope and
45+
* sentence and promotes nothing, while a prefixed draft still publishes.
46+
*/
47+
48+
interface Row {
49+
id: string;
50+
type: string;
51+
name: string;
52+
organization_id: string | null;
53+
package_id: string | null;
54+
state: string;
55+
metadata: string;
56+
checksum?: string;
57+
version?: number;
58+
}
59+
60+
function keyOf(w: Record<string, unknown>) {
61+
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
62+
}
63+
64+
/** Does row `r` satisfy `where` (top-level eq + `$or`)? */
65+
function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean {
66+
for (const [k, v] of Object.entries(where)) {
67+
if (k === '$or') {
68+
const clauses = v as Array<Record<string, unknown>>;
69+
if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false;
70+
continue;
71+
}
72+
// `undefined` = "dimension not constrained"; `null` = "must be NULL".
73+
if (v === undefined) continue;
74+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
75+
const op = v as Record<string, unknown>;
76+
if ('$in' in op) {
77+
if (!(op.$in as unknown[]).includes((r as any)[k])) return false;
78+
continue;
79+
}
80+
if ('$ne' in op) {
81+
if ((r as any)[k] === op.$ne) return false;
82+
continue;
83+
}
84+
}
85+
if ((r as any)[k] !== v) return false;
86+
}
87+
return true;
88+
}
89+
90+
const PKG = 'com.v1.rollup';
91+
const NS = 'rollup';
92+
/** A package that declares no namespace — legacy-grandfathered. */
93+
const BARE_PKG = 'com.v1.bare';
94+
95+
function makeStubEngine() {
96+
const rows = new Map<string, Row>();
97+
let nextId = 0;
98+
99+
const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => {
100+
if (w.id !== undefined) {
101+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
102+
return null;
103+
}
104+
for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r };
105+
return null;
106+
};
107+
108+
const manifests: Record<string, Record<string, unknown>> = {
109+
[PKG]: { id: PKG, namespace: NS },
110+
[BARE_PKG]: { id: BARE_PKG },
111+
};
112+
113+
const engine: any = {
114+
async findOne(table: string, opts: { where: Record<string, unknown> }) {
115+
assertEngineFindOnePredicate(table, opts);
116+
if (table !== 'sys_metadata') return null;
117+
return findRow(opts.where)?.row ?? null;
118+
},
119+
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
120+
if (table !== 'sys_metadata') return [];
121+
const matched = Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {}));
122+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
123+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
124+
},
125+
async insert(table: string, data: Record<string, unknown>) {
126+
nextId += 1;
127+
if (table !== 'sys_metadata') return { id: `x_${nextId}` };
128+
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
129+
rows.set(keyOf(data), row);
130+
return { id: row.id };
131+
},
132+
async update(table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
133+
assertEngineUpdateDispatch(data, opts);
134+
if (table !== 'sys_metadata') return { id: null };
135+
const found = findRow(opts.where);
136+
if (!found) return { id: null };
137+
const merged = { ...found.row, ...(data as any) };
138+
rows.delete(found.key);
139+
rows.set(keyOf(merged), merged);
140+
return { id: found.row.id };
141+
},
142+
async delete(table: string, opts: { where: Record<string, unknown> }) {
143+
assertEngineDeleteDispatch(opts);
144+
if (table !== 'sys_metadata') return { deleted: 0 };
145+
const found = findRow(opts.where);
146+
if (!found) return { deleted: 0 };
147+
rows.delete(found.key);
148+
return { deleted: 1 };
149+
},
150+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
151+
return cb(undefined, { owned: true });
152+
},
153+
registry: {
154+
registerItem: () => {},
155+
registerObject: () => {},
156+
getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined),
157+
},
158+
};
159+
return { engine, rows };
160+
}
161+
162+
const objectBody = (name: string, label = `Object ${name}`) => ({
163+
name,
164+
label,
165+
// [#8308] Authored OWD — the authoring gate refuses an OWD-less custom object.
166+
sharingModel: 'private',
167+
fields: {
168+
title: { type: 'text', label: 'Title' },
169+
},
170+
});
171+
172+
/** The stored rows for one object name, as `(state, package_id)` pairs. */
173+
const rowsFor = (rows: Map<string, Row>, name: string) =>
174+
Array.from(rows.values())
175+
.filter((r) => r.type === 'object' && r.name === name)
176+
.map((r) => ({ state: r.state, package_id: r.package_id }));
177+
178+
describe('[#24547 · ADR-0129 D2] the active save door refuses an object without its package namespace prefix', () => {
179+
it('refuses an unprefixed object under a namespaced package — NAMESPACE_PREFIX / 422, nothing written', async () => {
180+
const { engine, rows } = makeStubEngine();
181+
const protocol = new ObjectStackProtocolImplementation(engine);
182+
183+
const outcome = await protocol.saveMetaItem({
184+
type: 'object',
185+
name: 'v1_report',
186+
item: objectBody('v1_report'),
187+
packageId: PKG,
188+
}).then(() => 'saved', (e: unknown) => e);
189+
190+
expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 });
191+
// The prescription names the rename and the namespace it comes from.
192+
expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /);
193+
expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`);
194+
// Refused before any write: no row, active or draft, under any package.
195+
expect(rowsFor(rows, 'v1_report')).toEqual([]);
196+
});
197+
198+
it('saves the prefixed name under the same package', async () => {
199+
const { engine, rows } = makeStubEngine();
200+
const protocol = new ObjectStackProtocolImplementation(engine);
201+
202+
await protocol.saveMetaItem({
203+
type: 'object',
204+
name: `${NS}_v1_report`,
205+
item: objectBody(`${NS}_v1_report`),
206+
packageId: PKG,
207+
});
208+
209+
expect(rowsFor(rows, `${NS}_v1_report`)).toEqual([{ state: 'active', package_id: PKG }]);
210+
});
211+
212+
it('control: a package that declares no namespace saves the unprefixed name', async () => {
213+
const { engine, rows } = makeStubEngine();
214+
const protocol = new ObjectStackProtocolImplementation(engine);
215+
216+
await protocol.saveMetaItem({
217+
type: 'object',
218+
name: 'v1_report',
219+
item: objectBody('v1_report'),
220+
packageId: BARE_PKG,
221+
});
222+
223+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: BARE_PKG }]);
224+
});
225+
226+
it('control: a save naming no package (and resolving to none) saves the unprefixed name', async () => {
227+
const { engine, rows } = makeStubEngine();
228+
const protocol = new ObjectStackProtocolImplementation(engine);
229+
230+
await protocol.saveMetaItem({
231+
type: 'object',
232+
name: 'v1_report',
233+
item: objectBody('v1_report'),
234+
});
235+
236+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: null }]);
237+
});
238+
239+
it('judges a package-less publish save against the namespaced package it resolves into', async () => {
240+
const { engine, rows } = makeStubEngine();
241+
const protocol = new ObjectStackProtocolImplementation(engine);
242+
// An active row bound to the namespaced package already holds the
243+
// name (stored before this door read the rule), so a save naming no
244+
// package writes THAT package's row.
245+
await engine.insert('sys_metadata', {
246+
type: 'object',
247+
name: 'v1_report',
248+
organization_id: null,
249+
package_id: PKG,
250+
state: 'active',
251+
metadata: JSON.stringify(objectBody('v1_report')),
252+
checksum: 'sha256:stored',
253+
version: 1,
254+
});
255+
256+
const outcome = await protocol.saveMetaItem({
257+
type: 'object',
258+
name: 'v1_report',
259+
item: objectBody('v1_report', 'Edited'),
260+
}).then(() => 'saved', (e: unknown) => e);
261+
262+
expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 });
263+
// The stored row is untouched.
264+
const stored = Array.from(rows.values()).find((r) => r.name === 'v1_report');
265+
expect(JSON.parse(stored!.metadata).label).toBe('Object v1_report');
266+
});
267+
268+
it('a draft save is unchanged, and the publish door refuses that draft with the sentence the active door answers', async () => {
269+
const { engine, rows } = makeStubEngine();
270+
const protocol = new ObjectStackProtocolImplementation(engine);
271+
272+
const activeRefusal = await protocol.saveMetaItem({
273+
type: 'object',
274+
name: 'v1_report',
275+
item: objectBody('v1_report'),
276+
packageId: PKG,
277+
}).then(() => undefined, (e: Error) => e);
278+
279+
// The draft save stores, as before — the door that promotes a draft
280+
// is the gate it answers to.
281+
await protocol.saveMetaItem({
282+
type: 'object',
283+
name: 'v1_report',
284+
item: objectBody('v1_report'),
285+
packageId: PKG,
286+
mode: 'draft',
287+
});
288+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
289+
290+
const res = await protocol.publishPackageDrafts({ packageId: PKG } as any);
291+
const itemRefusal = await protocol.publishMetaItem({
292+
type: 'object',
293+
name: 'v1_report',
294+
packageId: PKG,
295+
}).then(() => undefined, (e: Error) => e);
296+
297+
expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1 });
298+
expect(res.failed[0]).toMatchObject({ type: 'object', name: 'v1_report', code: 'NAMESPACE_PREFIX' });
299+
// ONE rule, ONE sentence: the three doors cannot disagree on the
300+
// verdict or on what the author is told to do about it.
301+
expect(activeRefusal).toBeInstanceOf(Error);
302+
expect(itemRefusal).toBeInstanceOf(Error);
303+
expect(res.failed[0].error).toBe(activeRefusal!.message);
304+
expect(itemRefusal!.message).toBe(activeRefusal!.message);
305+
// Nothing went live.
306+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
307+
});
308+
309+
it('the per-item publish refuses an unprefixed object draft under a namespaced package — NAMESPACE_PREFIX / 422, the draft stays a draft', async () => {
310+
const { engine, rows } = makeStubEngine();
311+
const protocol = new ObjectStackProtocolImplementation(engine);
312+
await protocol.saveMetaItem({
313+
type: 'object',
314+
name: 'v1_report',
315+
item: objectBody('v1_report'),
316+
packageId: PKG,
317+
mode: 'draft',
318+
});
319+
320+
const outcome = await protocol.publishMetaItem({
321+
type: 'object',
322+
name: 'v1_report',
323+
packageId: PKG,
324+
}).then(() => 'published', (e: unknown) => e);
325+
326+
expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 });
327+
expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /);
328+
expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`);
329+
// Nothing promoted: no active row, the draft untouched.
330+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
331+
});
332+
333+
it('control: the per-item publish promotes a prefixed object draft under the same package', async () => {
334+
const { engine, rows } = makeStubEngine();
335+
const protocol = new ObjectStackProtocolImplementation(engine);
336+
await protocol.saveMetaItem({
337+
type: 'object',
338+
name: `${NS}_v1_report`,
339+
item: objectBody(`${NS}_v1_report`),
340+
packageId: PKG,
341+
mode: 'draft',
342+
});
343+
344+
const res = await protocol.publishMetaItem({
345+
type: 'object',
346+
name: `${NS}_v1_report`,
347+
packageId: PKG,
348+
});
349+
350+
expect(res.success).toBe(true);
351+
expect(rowsFor(rows, `${NS}_v1_report`)).toContainEqual({ state: 'active', package_id: PKG });
352+
expect(rowsFor(rows, `${NS}_v1_report`)).not.toContainEqual({ state: 'draft', package_id: PKG });
353+
});
354+
});

0 commit comments

Comments
 (0)