Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/23538-duplicate-names-in-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/metadata-protocol': patch
---

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

Clause-②: no

`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.

**FROM.**

- 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`.
- 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`).
- 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.

**TO.**

- 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.
- 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.
- A value that is a renamed view item's whole name follows it: the action's `target` becomes `crm2_ticket.entry`.

**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.
82 changes: 77 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26332,6 +26332,14 @@ export class ObjectStackProtocolImplementation implements
* there (a permission set's or a translation's `objects` key, a hook's
* `object` entry) is never merged: the item is refused in `failed[]`,
* naming both, before anything is written for it.
*
* Every copied name stays in step with what it names. A view item's
* `<object>.<viewKey>` name takes its object's copy name and keeps its view
* key (`ticket.board` becomes `iojn2_ticket.board`), and a value that is
* that whole name follows it; an item of an object the copy does not rename
* keeps its name. A record entry's `name` that echoes its key (an object's
* field) follows the key, and free text renames an object's name only as a
* whole identifier, never inside a longer one (`x_iojn_customer` stays).
*/
async duplicatePackage(request: {
sourcePackageId: string;
Expand Down Expand Up @@ -26461,8 +26469,9 @@ export class ObjectStackProtocolImplementation implements
// [#21911] The explicit system opt-in — see findServedOverlayRow.
const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[];

// Views/etc. are renamed by the source-prefix swap alone; a name without
// the prefix keeps it (the docblock re-namespaces OBJECT names).
// Rows other than objects and qualified view items (each below) are
// renamed by the source-prefix swap alone; a name without the prefix
// keeps it (the docblock re-namespaces OBJECT names).
const renameName = (name: string): string =>
sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`)
? `${targetNs}_${name.slice(sourceNs.length + 1)}`
Expand Down Expand Up @@ -26537,9 +26546,50 @@ export class ObjectStackProtocolImplementation implements
// `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
// Prefixed names only: a prefixed name is distinctive enough to find in
// free text, a bare one is not (below).
//
// ⭐ THE BOUNDARY HOLDS ON BOTH SIDES. With the right side alone, a
// name that merely ENDS in an object's name was rewritten inside it:
// the field `x_iojn_task` came out `x_iojn2_task` — its `name` and every
// value naming it — while its `fields` key stayed, so the copy's key
// and `name` disagreed. `x_iojn_task` is another identifier, never the
// object: `renameName` swaps the source prefix only at the START of a
// name, and this rewrite now matches only a whole identifier too.
const olds = prefixed.filter((name) => renameMap.has(name)).sort((a, b) => b.length - a.length);
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = olds.length ? new RegExp(`(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g') : null;
const re = olds.length
? new RegExp(`(?<![A-Za-z0-9_])(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g')
: null;
// ⭐ A VIEW ITEM'S NAME FOLLOWS ITS OBJECT. A view row's qualified name
// is `<object>.<viewKey>` (`ViewItemNameSchema`,
// `packages/spec/src/ui/view.zod.ts`: "the object can be recovered from
// the prefix, so the registry key never collides across objects"), so
// its object part takes exactly the rename its `object` property takes
// — `renameMap`, whole — and its view key is left as stored:
// `ticket.board` copies as `iojn2_ticket.board` beside the body's
// `object: 'iojn2_ticket'`. The source-prefix swap (`renameName`)
// reached a prefixed object's items only: a bare object's item kept
// `ticket.board`, a name the source's item still holds, so a by-name
// read naming no package served the source and the copy was shadowed.
// The swap also moved an item of an object OUTSIDE the package that
// carries the source prefix (`iojn_ext.board` → `iojn2_ext.board`)
// while its `object` stayed `iojn_ext`. An object this copy does not
// rename keeps its items' names.
//
// A value that IS such a name, whole, follows the item: a form action's
// `target` (`<object>.<viewKey>`) kept `ticket.entry`, so the copy's
// button opened the SOURCE's form, on the source's object. Whole values
// only, and only names of the view items this copy renames — a bare
// object's name is never rewritten inside free text (below).
const isQualifiedViewRow = (row: any): boolean =>
(PLURAL_TO_SINGULAR[String(row?.type)] ?? String(row?.type)) === 'view'
&& typeof row?.name === 'string' && row.name.includes('.');
const viewItemRenames = new Map<string, string>();
for (const row of rows) {
if (!isQualifiedViewRow(row)) continue;
const dot = (row.name as string).indexOf('.');
const renamed = renameMap.get((row.name as string).slice(0, dot));
if (renamed !== undefined) viewItemRenames.set(row.name, `${renamed}${(row.name as string).slice(dot)}`);
}
// ⭐ AT AN OBJECT-REFERENCE PROPERTY, A NAME IS REWRITTEN WHOLE — the
// only place a BARE name is rewritten. Measured: putting `ticket` in the
// free-text rewrite above turns "A support ticket raised by a customer"
Expand Down Expand Up @@ -26579,6 +26629,8 @@ export class ObjectStackProtocolImplementation implements
if (typeof v === 'string') {
const whole = atReference ? renameMap.get(v) : undefined;
if (whole !== undefined) return whole;
const viewItem = viewItemRenames.get(v);
if (viewItem !== undefined) return viewItem;
return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
}
if (Array.isArray(v)) {
Expand Down Expand Up @@ -26607,11 +26659,27 @@ export class ObjectStackProtocolImplementation implements
collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key });
}
from.set(key, k);
o[key] = deepRewrite(
const entry = deepRewrite(
val, collisions,
objectReferenceProperties.has(k) || (atReference && k === 'name'),
at ? `${at}.${k}` : k,
);
// ⭐ A RECORD ENTRY'S `name` FOLLOWS ITS KEY. An entry that
// echoes its key in `name` (an object's field: `fields`
// keyed by field name) is ONE identity spelled twice, so
// the name takes the key's rename decision. Outside a
// reference site a key is never renamed, while the
// free-text rewrite above renamed a `name` equal to a
// prefixed object's: the field `iojn_customer` was copied
// as `fields.iojn_customer.name: 'iojn2_customer'`. The
// data plane addresses the column by the key, the save
// door's FLS lint reads the `name`, so a copied permission
// set's rule on that field was refused at the copy.
if (typeof (val as any)?.name === 'string' && (val as any).name === k
&& entry && typeof entry === 'object' && !Array.isArray(entry)) {
entry.name = key;
}
o[key] = entry;
}
return o;
}
Expand Down Expand Up @@ -26713,7 +26781,11 @@ export class ObjectStackProtocolImplementation implements
failed.push({ type: row.type, name: row.name, error: refusal });
continue;
}
const newName = row?.type === 'object' ? (renameMap.get(row.name) ?? row.name) : renameName(row.name);
const newName = row?.type === 'object'
? (renameMap.get(row.name) ?? row.name)
: isQualifiedViewRow(row)
? (viewItemRenames.get(row.name) ?? row.name)
: renameName(row.name);
const rawType = String(row.type);
const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType;
let body: unknown;
Expand Down
Loading
Loading