Skip to content

Commit 56147a8

Browse files
fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace (#23525)
Fixes #23511 Clause-②: no ## What this does `duplicatePackage` (`packages/metadata-protocol/src/protocol.ts`, ADR-0070 D4) renamed a copied object only when its name started with the source namespace prefix (`dupsrc_customer` becomes `dupdst_customer`). An object whose name lacks the prefix (`ticket`) kept its name. An object name is its registry key and its table name, so that copy was a second claim on the source's object. On `main` at `222293f60` it lands in `failed[]` with the ADR-0029 D9.9 ownership text ("bind the customization to …, or have … extend the object instead"), which names no way out for a duplicate. Every copied item that referenced it still named the source's object. Now, in the `duplicatePackage` region only: - **Every copied object takes a name in the target namespace.** A prefixed name swaps its prefix, unchanged. A name without the source prefix gets the target prefix in front of its whole name: `ticket` becomes `dupdst_ticket`. That is the rename the namespace-prefix rule itself prescribes (`validateObjectNamespacePrefix`: "Rename it to NAMESPACE_NAME"). The copy gets its own registry entry and its own table. - **References to such a name are rewritten where a property names an object, and only on an exact match.** A bare word cannot be found safely in free text. Measured: putting `ticket` into the existing free-text rewrite turns "A support ticket raised by a customer" into "A support dupdst_ticket raised by a customer". The same match would also rename the value of a lookup field called `ticket`. So a bare name is rewritten only at the derived object-reference properties (`REFERENCE_SITES`, target `object`: `reference`, `object`, `objectName`, `objects`, `targetObject`). The rule reads the property wherever it occurs in any item, because a flow binds its object on a node `config.objectName`, which the index does not walk. It covers the four shapes a name takes there: a string, an array element, a record key, or a `.name`. - **A name that is already taken is refused up front, at the object.** The name can be taken by a prefixed sibling's copy (`ticket` and `dupsrc_ticket` both become `dupdst_ticket`) or by an object the source package holds. In that case the object is listed in `failed[]` before anything is written for it. The entry names the object, the name it would take, the holder of that name, and the rename on the source that clears it. No second name is invented. - **Bounded in-place fix, same defect class.** The record-key rule also applies to prefixed names. Measured on `main`: a copied permission set kept `objects: { dupsrc_customer, ticket }`, which granted on the SOURCE's objects. It now holds `dupdst_customer` and `dupdst_ticket`. It is the same walker in the same region, pinned by the references case below, and it adds no gate surface. Every other `failed[]` cause and every non-object item name is unchanged. ## The H2 reading: rename, and refuse only a claimed name - **ADR-0070 D4 docblock** (the contract): it clones every ACTIVE item "RE-NAMESPACING object names — … `sys_metadata` keys on (type,name,org), so a same-name copy would collide with the source". - **ADR-0129 D2** (the governing naming record): "A package declares its prefix once (`manifest.namespace`), and every object it defines MUST be named `${namespace}_${shortName}` (platform-reserved `sys_*` names exempt)." - **ADR-0028 is Deferred.** Its status line records the literal-prefix rule as the enforced current state, and ADR-0129 D4 says "Until 0028's recorded re-open trigger fires, this ADR is the governing record of object naming." So the rule applied is ADR-0129's literal prefix, `${targetNs}_${name}` with a single underscore. The `__` form is the legacy FQN form, which the prefix rule refuses. - **The target namespace is always known at the copy loop.** A missing or invalid one is refused `400` before anything is minted, so the copy is renamed whenever its name is free. The copy is a NEW object, so the rename costs nothing. Renaming the SOURCE object (the alternative way out) would move its table. - **The "illegal name" arm is unreachable, so no code was added for it.** The namespace declaration (`^[a-z][a-z0-9_]{1,19}$`) plus `_` plus a name the object declaration admitted (`^[a-z_][a-z0-9_]*$`) is always admitted. No length cap is declared on object names, so H2's length check has nothing to check. - **"Existing names" is read as the names this copy and its source hold.** A collision with a third package's registry entry is answered by the save door's own gates, exactly as for a prefixed copy, and is unchanged. ## Zone 2 hypotheses, measured on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver) - **H1 holds as guessed.** At `222293f60` the rename is a prefix swap, and a name without the prefix is left unchanged. The rename map holds only the swapped objects. References are rewritten by one free-text regex over every string value, and record keys are never touched. Measured: the copied `dupdst_note.fields.ticket.reference` stayed `ticket` (the source's object), and the copied permission set kept both source keys. - **H2:** see above. The PM route (the same map through the free-text regex) is the measured prose corruption, so the bare name goes through the structural rewrite instead. - **H3 holds.** At `222293f60`, `ticket` is in `failed[]` with the D9.9 text "Cannot layer object 'ticket': the overlay is bound to package 'com.qa.dupdst', but the object is owned by package 'com.qa.dupsrc' …". With this change that cause moves for this case only. The same pin file's other assertions and the package suites show no other cause moved. - **H4 holds.** A record inserted through `dupdst_ticket` lands in the `dupdst_ticket` table, and the `ticket` table holds only its own record. At base, `dupdst_ticket` does not exist (`Object 'dupdst_ticket' not found`). ## Pins The new file is `packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts`. It sits beside the existing duplicate tests in `protocol-package-lifecycle.test.ts`. The base column was read against a `metadata-protocol` dist built at `222293f60`. | case | at `222293f60` | at head | |:--|:--|:--| | a prefixed and a non-prefixed object both copy into the target namespace, each owned by the target; no target row under the source's name | red: `failed` holds `ticket` with the D9.9 text | green | | references follow: lookup `reference`, view `object`, flow node `config.objectName`, permission `objects` keys. NEGATIVE CONTROLS: the prose and the lookup field called `ticket` (key and `name`) are unchanged, and the source is untouched | red: no `dupdst_ticket` row | green | | the copy has its own table. NEGATIVE CONTROL: the source table holds only its own record | red: `Object 'dupdst_ticket' not found` | green | | a claimed name is refused at the object (asserts on named subjects: the object, the name it would take, the holder, the rename). NEGATIVE CONTROL: the prefixed sibling took the name with its own body, and one history entry was written for it | red: the D9.9 text instead | green | ## Ablation (one, on the non-prefixed branch) The mutation was `for (const name of unprefixed) {` replaced by `for (const name of unprefixed.slice(0, 0)) {`, so no bare name is renamed or refused. It ran through `scripts/ablation-replace.mjs`: anchor x1 to x0, blob `c19415261` to `0de914530`. After a rebuild, `ablation-dist-preflight` found the marker in 2 dist files. Result: 4 failed / 0 passed, for the base reasons (the D9.9 text, the missing copy, `Object 'dupdst_ticket' not found`). The restore was proven by the tool (blob equals HEAD `c19415261`, `git diff HEAD` empty). After a rebuild, the marker was absent from all 24 dist files, the tree was clean against HEAD, and the pin was 4 passed. The measurement of the PM route used the same discipline: `olds` widened to every renamed name, then 1 failed / 3 passed (the prose case), restored, marker absent, tree clean. ## Tests and gates - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 236 files passed, 3 skipped; 28397 tests passed, 19 skipped. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 417 files, 8101 tests passed. - Both suites ran at `c2430b9b4` (lock VERDICT command-exit 0). `e9c2bc9ad` only rewords one code comment. After it, `metadata-protocol` was rebuilt and the 14 duplicate pins reran green. - `metadata-protocol` typecheck: exit 0. - `objectql` typecheck: exit 0. `check:test-typecheck` OK with its debt unchanged, and the new file is in the test program (`--listFilesOnly`). - `node scripts/pm/dispatch-gates.mjs --commands` (diff-derived, 67 families) was run locally and reconciled with `--ran` at `e9c2bc9ad`: 67 derived, 66 run with exit 0, 1 NOT MEASURED, 0 unrun. - `check:lean-entry-closure` first refused its prerequisite. It reran green after an `objectql` build. - `check:dual-build-cjs-loads` is NOT MEASURED: `PREREQUISITE NOT MET`, because it needs the whole workspace built. It is left to CI. - Origin/main has moved 7 commits since the branch point. One of them, the read naming no package, touches other regions of `protocol.ts`. `git merge-tree` against it is clean. No merge was made, and the PR's merge-ref CI covers the joint state. ## Acceptance notes - **A non-object item whose name lacks the source prefix still keeps its name, as the docblock says.** Measured: the copy of the view container `ticket_views` is stored as a second row bound to the target. Its served views expand per object (`ticket.default` for the source, `dupdst_ticket.default` for the copy), and a read naming no package serves the source's row. No defect measured; noted. - **A bare name inside free text (an expression, a description) is not rewritten, by design.** A prefixed name still is, through the existing regex. - **Read-only inference, not measured:** the existing free-text regex has a right identifier boundary but no left one. - **Not measured:** physical identifier limits (63 bytes on PostgreSQL) for a longer target name. No object-name length cap is declared, and the prefixed path carries the same exposure. - **Out of scope, handed to the seat:** a copied permission set's `fields` map, keyed `OBJECT.FIELD`, keeps the source object's names. Measured at head: `dupsrc_customer.full_name` stays as is while `objects` follows. This is a composite key form the derived reference-site index does not model, so it is not fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 989aa48 commit 56147a8

3 files changed

Lines changed: 439 additions & 12 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): `duplicatePackage` copies an object whose name lacks the source namespace prefix into the target namespace, and a copied reference follows it
6+
7+
Clause-②: no
8+
9+
`duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace, because an object's name is its registry key and its table name, so a same-name copy is a second claim on the source's object.
10+
11+
**FROM.** Only a name that starts with the source prefix was renamed (`crm_account` → `crm2_account`). An object whose name lacks it (`ticket`, stored by a direct publish-mode save, which no prefix gate reads, or bound to a package that had no namespace) kept its name. The copy then failed: it was listed in `failed[]` with the object-ownership refusal ("bind the customization to 'com.acme.crm', or have 'com.acme.crm2' extend the object instead"), which names no way out for a duplicate. Before that refusal existed, the same object was listed in `copied` while the registry refused the target row, and the copy sat over the source's table. Every copied item that referenced it (a lookup `reference`, a view's `object`, a flow node's `objectName`) still named the source's object.
12+
13+
**TO.** Such an object is copied as `<target namespace>_<name>` (`ticket` → `crm2_ticket`), the rename the namespace-prefix rule itself prescribes (ADR-0129 D2), with its own registry entry and its own table. Every reference to it is rewritten where a property names an object, and only when the value is exactly the object's name. A field that happens to be called `ticket`, and the word in a label or description, are left unchanged. When that target name is already taken (the source also holds `crm_ticket`, whose copy is `crm2_ticket`, or the source holds an object of that name), the object is listed in `failed[]` before anything is written for it. The entry names the object, the name it would take and who holds that name, and says to rename the object on the source.
14+
15+
**Also.** A map keyed by object name (a permission set's `objects`, a translation's `objects`) now has its keys follow the copied objects, prefixed names included. So the duplicate's permission set grants on the duplicate's objects. Before, it kept the source's names and granted on the source.
16+
17+
**Unchanged.** A prefixed object's copy name, a non-object item's name, and every other `failed[]` cause are unchanged.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26290,6 +26290,15 @@ export class ObjectStackProtocolImplementation implements
2629026290
* with the source — and rewriting every intra-package reference (lookup
2629126291
* `reference`, view `object`, expressions, etc.) to the new names. Per-item
2629226292
* best-effort; one failure never aborts the whole clone.
26293+
*
26294+
* Every copied object is named in the target namespace: `iojn_customer`
26295+
* becomes `iojn2_customer`, and an object whose name lacks the source
26296+
* prefix (`ticket`) becomes `iojn2_ticket` — the prefix rule's own rename
26297+
* (ADR-0129 D2). Such a name is rewritten where it is REFERENCED (an
26298+
* object-reference property, exact value), never inside free text; and
26299+
* when its target name is already claimed (the source also holds
26300+
* `iojn_ticket`) the object is refused in `failed[]`, before anything is
26301+
* written for it, with the source rename that clears it.
2629326302
*/
2629426303
async duplicatePackage(request: {
2629526304
sourcePackageId: string;
@@ -26419,30 +26428,116 @@ export class ObjectStackProtocolImplementation implements
2641926428
// [#21911] The explicit system opt-in — see findServedOverlayRow.
2642026429
const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[];
2642126430

26422-
// Map only OBJECT names that carry the source namespace prefix; views/etc.
26423-
// are renamed by the same prefix swap and reference-rewritten via the map.
26431+
// Views/etc. are renamed by the source-prefix swap alone; a name without
26432+
// the prefix keeps it (the docblock re-namespaces OBJECT names).
2642426433
const renameName = (name: string): string =>
2642526434
sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`)
2642626435
? `${targetNs}_${name.slice(sourceNs.length + 1)}`
2642726436
: name;
26437+
26438+
// ⭐ EVERY OBJECT GETS A NAME IN THE TARGET NAMESPACE (ADR-0070 D4, read
26439+
// with ADR-0129 D2). An object name IS its table name and its registry
26440+
// key, so a copy that keeps the source's name is a second claim on the
26441+
// source's object: before PR #23510 it stored a target row the registry
26442+
// refused, over the source's table, and after it the copy failed with
26443+
// the ADR-0029 D9.9 ownership prescription, which names no way out for
26444+
// a duplicate. ADR-0129 D2 says what the name is: every object a
26445+
// package defines is `${namespace}_${shortName}`, and the prefix rule's
26446+
// own prescription for a name that lacks the prefix
26447+
// (`validateObjectNamespacePrefix`) is "rename it to
26448+
// `${namespace}_${name}`". The copy is a NEW object, so that rename
26449+
// costs nothing here — while renaming the SOURCE object would move its
26450+
// table. A prefixed name swaps the source prefix for the target's;
26451+
// a name WITHOUT the source prefix (a direct-publish save, which no
26452+
// prefix gate reads, or a package that had no namespace) takes the
26453+
// target prefix in front of its whole name.
26454+
//
26455+
// ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by
26456+
// the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name
26457+
// `iojn2_ticket`) or by an object the source package holds — the object
26458+
// is reported in `failed[]` before anything is written for it, at its
26459+
// own name, with the rename that clears it. A second invented name
26460+
// (a numeric suffix, a third prefix) would be a name nobody wrote.
26461+
const objectNames = new Set<string>(
26462+
rows.filter((row) => row?.type === 'object' && typeof row.name === 'string').map((row) => row.name as string),
26463+
);
2642826464
const renameMap = new Map<string, string>();
26429-
for (const row of rows) {
26430-
if (row?.type === 'object') {
26431-
const nn = renameName(row.name);
26432-
if (nn !== row.name) renameMap.set(row.name, nn);
26465+
const prefixed: string[] = [];
26466+
const unprefixed: string[] = [];
26467+
for (const name of objectNames) {
26468+
if (sourceNs && name.startsWith(`${sourceNs}_`)) prefixed.push(name);
26469+
else unprefixed.push(name);
26470+
}
26471+
for (const name of prefixed) {
26472+
const nn = renameName(name);
26473+
if (nn !== name) renameMap.set(name, nn);
26474+
}
26475+
// Every name a prefixed object's copy takes (itself, when the target
26476+
// namespace IS the source's), and every name the source holds.
26477+
const claimedBy = new Map<string, string>(prefixed.map((name) => [renameName(name), name]));
26478+
const refusedObjects = new Map<string, string>();
26479+
for (const name of unprefixed) {
26480+
const nn = `${targetNs}_${name}`;
26481+
const holder = claimedBy.get(nn) ?? (objectNames.has(nn) ? nn : undefined);
26482+
if (holder !== undefined) {
26483+
const lacks = sourceNs
26484+
? `lacks the '${sourceNs}_' prefix of package '${request.sourcePackageId}' (ADR-0129 D2: `
26485+
+ 'every object a package defines carries its namespace as a name prefix)'
26486+
: `carries no namespace prefix (package '${request.sourcePackageId}' declares no namespace)`;
26487+
const taken = holder === nn
26488+
? `'${nn}' is an object package '${request.sourcePackageId}' already holds`
26489+
: `'${nn}' is already the copy name of object '${holder}'`;
26490+
refusedObjects.set(
26491+
name,
26492+
`Object '${name}' is not copied: its name ${lacks}, so its copy is named '${nn}' in `
26493+
+ `namespace '${targetNs}', and ${taken}. Rename '${name}' on the source to `
26494+
+ `${sourceNs ? `a '${sourceNs}_' name` : 'a name'} no other object in the package uses, `
26495+
+ 'then duplicate again.',
26496+
);
26497+
continue;
2643326498
}
26499+
renameMap.set(name, nn);
26500+
claimedBy.set(nn, name);
2643426501
}
26502+
2643526503
// Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts
2643626504
// `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
26437-
const olds = [...renameMap.keys()].sort((a, b) => b.length - a.length);
26505+
// Prefixed names only: a prefixed name is distinctive enough to find in
26506+
// free text, a bare one is not (below).
26507+
const olds = prefixed.filter((name) => renameMap.has(name)).sort((a, b) => b.length - a.length);
2643826508
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2643926509
const re = olds.length ? new RegExp(`(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g') : null;
26440-
const deepRewrite = (v: any): any => {
26441-
if (typeof v === 'string') return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
26442-
if (Array.isArray(v)) return v.map(deepRewrite);
26510+
// ⭐ AT AN OBJECT-REFERENCE PROPERTY, A NAME IS REWRITTEN WHOLE — the
26511+
// only place a BARE name is rewritten. Measured: putting `ticket` in the
26512+
// free-text rewrite above turns "A support ticket raised by a customer"
26513+
// into "A support iojn2_ticket raised …", and the same match renames the
26514+
// value of a lookup FIELD called `ticket` (`fields.ticket.name`). So a
26515+
// value is rewritten only where it sits under a property that names an
26516+
// object — the derived site index (`REFERENCE_SITES`, target `object`:
26517+
// `reference`, `object`, `objectName`, `objects`, …), read by property
26518+
// wherever it occurs in any item, because flow node configs (`config.
26519+
// objectName`) are declared by node descriptors the index does not
26520+
// walk — and only when it equals a renamed name exactly, in the four
26521+
// shapes a name arrives in there: a string, an array element, a record
26522+
// KEY (`permission.objects` is keyed by object name) or a `.name`. The
26523+
// record key holds for prefixed names too: a copied permission set
26524+
// otherwise kept `objects.<source object>`, a grant on the SOURCE.
26525+
const objectReferenceProperties = new Set(
26526+
(REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property),
26527+
);
26528+
const deepRewrite = (v: any, atReference = false): any => {
26529+
if (typeof v === 'string') {
26530+
const whole = atReference ? renameMap.get(v) : undefined;
26531+
if (whole !== undefined) return whole;
26532+
return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
26533+
}
26534+
if (Array.isArray(v)) return v.map((element) => deepRewrite(element, atReference));
2644326535
if (v && typeof v === 'object') {
2644426536
const o: any = {};
26445-
for (const [k, val] of Object.entries(v)) o[k] = deepRewrite(val);
26537+
for (const [k, val] of Object.entries(v)) {
26538+
const key = atReference ? (renameMap.get(k) ?? k) : k;
26539+
o[key] = deepRewrite(val, objectReferenceProperties.has(k) || (atReference && k === 'name'));
26540+
}
2644626541
return o;
2644726542
}
2644826543
return v;
@@ -26478,7 +26573,12 @@ export class ObjectStackProtocolImplementation implements
2647826573
const canonicalizeFlow = this.resolveFlowCanonicalizer();
2647926574

2648026575
for (const row of rows) {
26481-
const newName = renameName(row.name);
26576+
const refusal = row?.type === 'object' ? refusedObjects.get(row.name) : undefined;
26577+
if (refusal !== undefined) {
26578+
failed.push({ type: row.type, name: row.name, error: refusal });
26579+
continue;
26580+
}
26581+
const newName = row?.type === 'object' ? (renameMap.get(row.name) ?? row.name) : renameName(row.name);
2648226582
const rawType = String(row.type);
2648326583
const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType;
2648426584
let body: unknown;

0 commit comments

Comments
 (0)