Skip to content

Commit cdc42eb

Browse files
fix(metadata-protocol): duplicatePackage keeps a copied field's key and name, and a bare object's view item name, in step with what they name (#23559)
Fixes #23538 Clause-②: no ## What this does `duplicatePackage` (`packages/metadata-protocol/src/protocol.ts`, ADR-0070 D4) promises to rewrite "every intra-package reference". Three copied names fell out of step with what they name. All edits are in the `duplicatePackage` region: the free-text rename, the row-name rename and `deepRewrite`. The source-row read is untouched. 1. **A field's key and its `name` agree.** The free-text regex had a right identifier boundary and no left one, and an object's `fields` keys are never renamed outside a reference site. So the field keyed `dupsrc_customer` was copied with `name: 'dupdst_customer'`, and `x_dupsrc_customer` with `name: 'x_dupdst_customer'`. Two rules now hold: - **A record entry's `name` that echoes its key follows the key** (`deepRewrite`). Both are kept, "through none", the reading consistent with the landed permission-field rule ("the field part is never read"). - **Free text renames an object's name only as a whole identifier.** A left boundary is added: a negative lookbehind that refuses a match preceded by a letter, digit or underscore. `x_dupsrc_customer` stays in a description and in a view's `columns`, the way `renameName` swaps the prefix only at the start of a name. 2. **A view item's `OBJECT.VIEWKEY` name follows its object.** For a `view` row with a qualified name, the object part takes `renameMap`'s rename, whole, and the view key is left as stored: `ticket.board` copies as `dupdst_ticket.board`. That is exactly the rename its `object` property takes. An object the copy does not rename keeps its items' names. This also covers an outside object that carries the source prefix: `dupsrc_ext.board` used to become `dupdst_ext.board` while its `object` stayed `dupsrc_ext`. 3. **The noted view-item id inside a value reproduced, so it is carried here (triage scope).** A value that is a renamed view item's whole name follows it: a form action's `target: 'ticket.entry'` becomes `dupdst_ticket.entry`. This applies to whole values only, and only to names of view items this copy renames. Mechanism assumptions (Zone 2), measured on `origin/main` `8226755ee9`: - Item 1: **partly confirmed.** The missing left boundary explains `x_dupsrc_customer` only. A value EQUAL to the object's name (`dupsrc_customer`) needs no left boundary to match, so the left boundary alone does not satisfy the Done line. The field's `name` has to follow its key's decision. Both changes landed, each with its own pin and ablation. - Item 2: **confirmed.** `renameName` renames only names with the source prefix. A bare object's item kept `ticket.board`, and `getMetaItem({ type: 'view', name: 'ticket.board' })` served the source (`packageId: com.qa.dupsrc`, `object: ticket`). ## Evidence (real kernel: ObjectKernel + ObjectQLPlugin, memory driver) New pin file `packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts`, 5 cases, each with its negative control: | case | on `main` `8226755ee9` | on this head | |---|---|---| | field key and `name` agree; `x_dupsrc_customer` keeps both (neg. control); the data plane inserts by the key; the copied set's FLS rules land | red: `failed[]` holds `dupsrc_ops`, `security-fls-unknown-field` on `dupdst_customer.dupsrc_customer` and `dupdst_customer.x_dupsrc_customer` | green | | free text renames only a whole identifier (description, view `columns`) | red: `see x_dupdst_customer.` | green | | bare object's view item is `dupdst_ticket.board`; the by-name read serves the copy, and `ticket.board` serves the source | red: copy stored as `ticket.board` | green | | view items of objects outside the package keep their names (`dupext_account.board`, `dupsrc_ext.board`) | red: `dupsrc_ext.board` copied as `dupdst_ext.board` | green | | a form action's view-item target follows (prefixed control; outside-object negative control) | red: `target: 'ticket.entry'` | green | Main run: `Tests 5 failed (5)`. Head run: `Tests 29 passed (29)` over the five duplicate suites (`names-in-step`, `unprefixed-object`, `permission-field-keys`, `objects-key-collision`, `protocol-package-lifecycle`). **Ablations.** One per mutation, each through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed). Then `pnpm --filter @objectstack/metadata-protocol build` and `ablation-dist-preflight` (marker present in 2 built files). Then the pin file. Then the restore, proven by blob == HEAD `3d7ac7e2e7b3` and an empty `git diff HEAD`, then a rebuild and `--absent` preflight (marker absent from all 24 built files, tree clean). | ablation | predicted | observed | |---|---|---| | A1: drop the left boundary | case 2 red | **4 red, 1 green: more diagnostics than predicted.** The copied list view's column `x_dupdst_customer` names no field of the copy, so the save door refused that view (`list-view-field-unknown`), which also reds cases 1, 3 and 4 | | A2: drop "name follows key" | case 1 red | 1 red (case 1), 4 green | | A3: view row back to the prefix swap | cases 3, 4 red | 2 red (cases 3, 4), 3 green | | A4: drop the whole-value view-item rename | case 5 red | 1 red (case 5), 4 green | ## Tests and gates (final head `dbd69f7592`, after merging `origin/main` `086fa4640f`) - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: `Test Files 236 passed | 3 skipped (239)`, `Tests 28409 passed | 19 skipped`. - `@objectstack/objectql` `--project local`, as two shards: 210 + 210 files, `Tests 4052 passed` + `Tests 4067 passed`. - `pnpm --filter @objectstack/metadata-protocol typecheck` exit 0. `pnpm --filter @objectstack/objectql typecheck` exit 0 (`check:test-typecheck: OK`). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from a fresh tree. Every exit was captured before any pipe. `--ran` printed: `67 derived famil(ies) accounted for — 66 run, 1 NOT-MEASURED`. - The one is `pnpm check:dual-build-cjs-loads`, which exited 3 with `PREREQUISITE NOT MET` (it needs every package's `dist/`; a full `pnpm build` is outside this card's local scope). NOT MEASURED locally; CI's `Lint & Repo Gates` runs it. - The other 66 exited 0. ## Acceptance notes - **Residual, same family, not addressed here (class a, reported for the seat to file).** A value naming a FIELD spelled like a prefixed package object is still renamed by the free-text rewrite as if it named the object. The field keeps its key and now its `name`. Measured with field `dupsrc_customer` on `dupsrc_customer`: - On this head, a list view with `columns: ['full_name', 'dupsrc_customer']` is **refused at the copy**: `view/dupdst_customer.board failed author-time validation … config.columns[1] [list-view-field-unknown]`. A form view with `sections[].fields` naming it copies silently as `['full_name', 'dupdst_customer']`. - On `main` `8226755ee9` the list view copied with column `dupdst_customer`, which matches the renamed `name` but not the key the data plane addresses (`engine.insert('dupdst_customer', { dupdst_customer })` answers `Unknown field 'dupdst_customer'`). - So this change moves the list-view case from a silent dangling column to a loud refusal, just as #23536 moved the permission-set case the other way. - Closing it needs a semantics decision about a token that names both an object and a field. One option is to rename such a token only at object-reference sites, as a bare name already is. That decision is left open, not guessed. - **Not measured, read from code:** a view CONTAINER name (no dot, e.g. `ticket_views`) is still renamed by the prefix swap alone, so a bare object's container keeps the source's name. A container name is ungrammared (no declared `OBJECT.` identity), so this is not a contract breach. - **Ruled negative control, kept:** a view item of an object outside the package keeps its name, so the copy shares that name with the source's row. - **File surface:** besides the claimed region, the pin and the changeset, a 6-line doc comment in the sibling pin `protocol-duplicate-package-permission-field-keys.test.ts` is updated. It described this defect as live ("renames such a field's `name` but not its `fields` key … a separate defect"), which this change makes false. It now points at the new pin file. - The card's blocker #23537 landed as PR #23545 (`8226755ee9`). #23539 remains open and is untouched here (`referencedNamesAt`, `collectReferenceHits`, `findReferencesToMeta` and `reference-sites.ts` are not edited). PR #23469's source-row read line is not edited either. Changeset: `.changeset/23538-duplicate-names-in-step.md`, `@objectstack/metadata-protocol` `patch`. Session `session_0121ooBYRmS8ziFXm9ai5YHm`, os-dev for `domain:spec` seat 4. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 252775d commit cdc42eb

4 files changed

Lines changed: 435 additions & 10 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): `duplicatePackage` keeps a copied field's key and `name` in step, and gives a bare object's view item the copied object's `OBJECT.VIEWKEY` name
6+
7+
Clause-②: no
8+
9+
`duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace and rewrites the references to it. Three copied names fell out of step with what they name.
10+
11+
**FROM.**
12+
13+
- A field named after a prefixed object, or ending in its name, kept its `fields` key but had its `name` renamed. Duplicating `crm` into `crm2`, the field `crm_customer` of `crm_customer` was copied as `fields.crm_customer.name: 'crm2_customer'`, and `x_crm_customer` as `x_crm2_customer`. The data plane addresses the column by the key. The save door's FLS lint reads the `name`, so a copied permission set with a rule on such a field was refused at the copy with `security-fls-unknown-field`.
14+
- A view item of an object whose name lacks the source prefix kept its name. `ticket.board` was copied under that name with `object: 'crm2_ticket'`, so a by-name read naming no package served the source's item. A view item of an object outside the package that carries the source prefix was renamed while its `object` stayed (`crm_ext.board` became `crm2_ext.board`, bound to `crm_ext`).
15+
- A form action's `target` naming such a view item (`ticket.entry`) kept the source's name, so the copy's button opened the source's form.
16+
17+
**TO.**
18+
19+
- A field's `name` follows its key, and both are kept: `crm_customer` and `x_crm_customer` keep their names. Free text renames an object's name only as a whole identifier, so `x_crm_customer` stays in a description or a view's columns too.
20+
- A view item's name takes its object's copy name and keeps its view key: `ticket.board` becomes `crm2_ticket.board`. An item of an object the copy does not rename keeps its name.
21+
- A value that is a renamed view item's whole name follows it: the action's `target` becomes `crm2_ticket.entry`.
22+
23+
**Unchanged.** Object names, object references and a permission set's `objects` and `fields` keys are renamed as before. The source package is never modified. To bring an earlier copy in step, delete it and duplicate the source again.

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

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26374,6 +26374,14 @@ export class ObjectStackProtocolImplementation implements
2637426374
* there (a permission set's or a translation's `objects` key, a hook's
2637526375
* `object` entry) is never merged: the item is refused in `failed[]`,
2637626376
* naming both, before anything is written for it.
26377+
*
26378+
* Every copied name stays in step with what it names. A view item's
26379+
* `<object>.<viewKey>` name takes its object's copy name and keeps its view
26380+
* key (`ticket.board` becomes `iojn2_ticket.board`), and a value that is
26381+
* that whole name follows it; an item of an object the copy does not rename
26382+
* keeps its name. A record entry's `name` that echoes its key (an object's
26383+
* field) follows the key, and free text renames an object's name only as a
26384+
* whole identifier, never inside a longer one (`x_iojn_customer` stays).
2637726385
*/
2637826386
async duplicatePackage(request: {
2637926387
sourcePackageId: string;
@@ -26503,8 +26511,9 @@ export class ObjectStackProtocolImplementation implements
2650326511
// [#21911] The explicit system opt-in — see findServedOverlayRow.
2650426512
const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[];
2650526513

26506-
// Views/etc. are renamed by the source-prefix swap alone; a name without
26507-
// the prefix keeps it (the docblock re-namespaces OBJECT names).
26514+
// Rows other than objects and qualified view items (each below) are
26515+
// renamed by the source-prefix swap alone; a name without the prefix
26516+
// keeps it (the docblock re-namespaces OBJECT names).
2650826517
const renameName = (name: string): string =>
2650926518
sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`)
2651026519
? `${targetNs}_${name.slice(sourceNs.length + 1)}`
@@ -26579,9 +26588,50 @@ export class ObjectStackProtocolImplementation implements
2657926588
// `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
2658026589
// Prefixed names only: a prefixed name is distinctive enough to find in
2658126590
// free text, a bare one is not (below).
26591+
//
26592+
// ⭐ THE BOUNDARY HOLDS ON BOTH SIDES. With the right side alone, a
26593+
// name that merely ENDS in an object's name was rewritten inside it:
26594+
// the field `x_iojn_task` came out `x_iojn2_task` — its `name` and every
26595+
// value naming it — while its `fields` key stayed, so the copy's key
26596+
// and `name` disagreed. `x_iojn_task` is another identifier, never the
26597+
// object: `renameName` swaps the source prefix only at the START of a
26598+
// name, and this rewrite now matches only a whole identifier too.
2658226599
const olds = prefixed.filter((name) => renameMap.has(name)).sort((a, b) => b.length - a.length);
2658326600
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26584-
const re = olds.length ? new RegExp(`(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g') : null;
26601+
const re = olds.length
26602+
? new RegExp(`(?<![A-Za-z0-9_])(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g')
26603+
: null;
26604+
// ⭐ A VIEW ITEM'S NAME FOLLOWS ITS OBJECT. A view row's qualified name
26605+
// is `<object>.<viewKey>` (`ViewItemNameSchema`,
26606+
// `packages/spec/src/ui/view.zod.ts`: "the object can be recovered from
26607+
// the prefix, so the registry key never collides across objects"), so
26608+
// its object part takes exactly the rename its `object` property takes
26609+
// — `renameMap`, whole — and its view key is left as stored:
26610+
// `ticket.board` copies as `iojn2_ticket.board` beside the body's
26611+
// `object: 'iojn2_ticket'`. The source-prefix swap (`renameName`)
26612+
// reached a prefixed object's items only: a bare object's item kept
26613+
// `ticket.board`, a name the source's item still holds, so a by-name
26614+
// read naming no package served the source and the copy was shadowed.
26615+
// The swap also moved an item of an object OUTSIDE the package that
26616+
// carries the source prefix (`iojn_ext.board` → `iojn2_ext.board`)
26617+
// while its `object` stayed `iojn_ext`. An object this copy does not
26618+
// rename keeps its items' names.
26619+
//
26620+
// A value that IS such a name, whole, follows the item: a form action's
26621+
// `target` (`<object>.<viewKey>`) kept `ticket.entry`, so the copy's
26622+
// button opened the SOURCE's form, on the source's object. Whole values
26623+
// only, and only names of the view items this copy renames — a bare
26624+
// object's name is never rewritten inside free text (below).
26625+
const isQualifiedViewRow = (row: any): boolean =>
26626+
(PLURAL_TO_SINGULAR[String(row?.type)] ?? String(row?.type)) === 'view'
26627+
&& typeof row?.name === 'string' && row.name.includes('.');
26628+
const viewItemRenames = new Map<string, string>();
26629+
for (const row of rows) {
26630+
if (!isQualifiedViewRow(row)) continue;
26631+
const dot = (row.name as string).indexOf('.');
26632+
const renamed = renameMap.get((row.name as string).slice(0, dot));
26633+
if (renamed !== undefined) viewItemRenames.set(row.name, `${renamed}${(row.name as string).slice(dot)}`);
26634+
}
2658526635
// ⭐ AT AN OBJECT-REFERENCE PROPERTY, A NAME IS REWRITTEN WHOLE — the
2658626636
// only place a BARE name is rewritten. Measured: putting `ticket` in the
2658726637
// free-text rewrite above turns "A support ticket raised by a customer"
@@ -26621,6 +26671,8 @@ export class ObjectStackProtocolImplementation implements
2662126671
if (typeof v === 'string') {
2662226672
const whole = atReference ? renameMap.get(v) : undefined;
2662326673
if (whole !== undefined) return whole;
26674+
const viewItem = viewItemRenames.get(v);
26675+
if (viewItem !== undefined) return viewItem;
2662426676
return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
2662526677
}
2662626678
if (Array.isArray(v)) {
@@ -26649,11 +26701,27 @@ export class ObjectStackProtocolImplementation implements
2664926701
collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key });
2665026702
}
2665126703
from.set(key, k);
26652-
o[key] = deepRewrite(
26704+
const entry = deepRewrite(
2665326705
val, collisions,
2665426706
objectReferenceProperties.has(k) || (atReference && k === 'name'),
2665526707
at ? `${at}.${k}` : k,
2665626708
);
26709+
// ⭐ A RECORD ENTRY'S `name` FOLLOWS ITS KEY. An entry that
26710+
// echoes its key in `name` (an object's field: `fields`
26711+
// keyed by field name) is ONE identity spelled twice, so
26712+
// the name takes the key's rename decision. Outside a
26713+
// reference site a key is never renamed, while the
26714+
// free-text rewrite above renamed a `name` equal to a
26715+
// prefixed object's: the field `iojn_customer` was copied
26716+
// as `fields.iojn_customer.name: 'iojn2_customer'`. The
26717+
// data plane addresses the column by the key, the save
26718+
// door's FLS lint reads the `name`, so a copied permission
26719+
// set's rule on that field was refused at the copy.
26720+
if (typeof (val as any)?.name === 'string' && (val as any).name === k
26721+
&& entry && typeof entry === 'object' && !Array.isArray(entry)) {
26722+
entry.name = key;
26723+
}
26724+
o[key] = entry;
2665726725
}
2665826726
return o;
2665926727
}
@@ -26755,7 +26823,11 @@ export class ObjectStackProtocolImplementation implements
2675526823
failed.push({ type: row.type, name: row.name, error: refusal });
2675626824
continue;
2675726825
}
26758-
const newName = row?.type === 'object' ? (renameMap.get(row.name) ?? row.name) : renameName(row.name);
26826+
const newName = row?.type === 'object'
26827+
? (renameMap.get(row.name) ?? row.name)
26828+
: isQualifiedViewRow(row)
26829+
? (viewItemRenames.get(row.name) ?? row.name)
26830+
: renameName(row.name);
2675926831
const rawType = String(row.type);
2676026832
const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType;
2676126833
let body: unknown;

0 commit comments

Comments
 (0)