Skip to content

Commit ff195fa

Browse files
committed
fix(metadata-protocol): duplicatePackage refuses an item whose renamed reference meets a name it already holds
WIP: the collision detection in deepRewrite and the failed[] call site. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b64a3d commit ff195fa

1 file changed

Lines changed: 72 additions & 4 deletions

File tree

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26327,6 +26327,11 @@ export class ObjectStackProtocolImplementation implements
2632726327
* A copied permission set's field rules follow too: in `fields`, keyed
2632826328
* `<object>.<field>`, a key's object part takes the renamed object's name
2632926329
* and its field part is left as stored.
26330+
*
26331+
* A rename at a reference site that meets a name the item already holds
26332+
* there (a permission set's or a translation's `objects` key, a hook's
26333+
* `object` entry) is never merged: the item is refused in `failed[]`,
26334+
* naming both, before anything is written for it.
2633026335
*/
2633126336
async duplicatePackage(request: {
2633226337
sourcePackageId: string;
@@ -26550,26 +26555,75 @@ export class ObjectStackProtocolImplementation implements
2655026555
// KEY (`permission.objects` is keyed by object name) or a `.name`. The
2655126556
// record key holds for prefixed names too: a copied permission set
2655226557
// otherwise kept `objects.<source object>`, a grant on the SOURCE.
26558+
//
26559+
// ⛔ REFUSED, NEVER MERGED: at a reference site, a renamed name can
26560+
// meet a name the item already holds there — the source also names
26561+
// the object the copy is about to be called (`dupdst_customer`, an
26562+
// object outside the package or not yet created) beside its own
26563+
// (`dupsrc_customer`). Two record KEYS that land on one key keep only
26564+
// the last entry: a permission set's `objects` dropped a grant (and
26565+
// the copied object could gain edit and delete its source never gave
26566+
// it), a translation's `objects` dropped a label. Two array ELEMENTS
26567+
// that land on one name bind it twice: a hook's handler ran twice per
26568+
// event on the copied object. So every such meeting is recorded in
26569+
// `collisions` — the path, the name that moved, the name it met — and
26570+
// the call site refuses the item in `failed[]` before anything is
26571+
// written for it. `renameMap` is one-to-one, so a meeting is always a
26572+
// renamed name and a name this copy does not rename; an element the
26573+
// source itself repeats is copied as stored.
2655326574
const objectReferenceProperties = new Set(
2655426575
(REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property),
2655526576
);
26556-
const deepRewrite = (v: any, atReference = false): any => {
26577+
type ReferenceCollision = { at: string; shape: 'key' | 'entry'; moved: string; met: string };
26578+
const deepRewrite = (v: any, collisions: ReferenceCollision[], atReference = false, at = ''): any => {
2655726579
if (typeof v === 'string') {
2655826580
const whole = atReference ? renameMap.get(v) : undefined;
2655926581
if (whole !== undefined) return whole;
2656026582
return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
2656126583
}
26562-
if (Array.isArray(v)) return v.map((element) => deepRewrite(element, atReference));
26584+
if (Array.isArray(v)) {
26585+
const out = v.map((element, i) => deepRewrite(element, collisions, atReference, `${at}[${i}]`));
26586+
if (atReference) {
26587+
const firstAt = new Map<string, number>();
26588+
out.forEach((element, i) => {
26589+
if (typeof element !== 'string') return;
26590+
const j = firstAt.get(element);
26591+
if (j === undefined) {
26592+
firstAt.set(element, i);
26593+
} else if (v[j] !== v[i]) {
26594+
collisions.push({ at, shape: 'entry', moved: v[i] !== element ? v[i] : v[j], met: element });
26595+
}
26596+
});
26597+
}
26598+
return out;
26599+
}
2656326600
if (v && typeof v === 'object') {
2656426601
const o: any = {};
26602+
const from = new Map<string, string>();
2656526603
for (const [k, val] of Object.entries(v)) {
2656626604
const key = atReference ? (renameMap.get(k) ?? k) : k;
26567-
o[key] = deepRewrite(val, objectReferenceProperties.has(k) || (atReference && k === 'name'));
26605+
const other = from.get(key);
26606+
if (other !== undefined) {
26607+
collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key });
26608+
}
26609+
from.set(key, k);
26610+
o[key] = deepRewrite(
26611+
val, collisions,
26612+
objectReferenceProperties.has(k) || (atReference && k === 'name'),
26613+
at ? `${at}.${k}` : k,
26614+
);
2656826615
}
2656926616
return o;
2657026617
}
2657126618
return v;
2657226619
};
26620+
const describeCollision = (c: ReferenceCollision): string =>
26621+
`its \`${c.at}\` ${c.shape} '${c.moved}' follows the copied object to '${c.met}', `
26622+
+ `${c.shape === 'key' ? 'a key' : 'an entry'} it already holds there, which this copy does not rename. `
26623+
+ (c.shape === 'key'
26624+
? 'A record keeps one entry per key, so the copy would drop one of the two. '
26625+
: `The copy would name '${c.met}' twice. `)
26626+
+ `Remove '${c.moved}' or '${c.met}' on the source, then duplicate again.`;
2657326627
// ⭐ A COPIED PERMISSION SET'S FIELD RULES FOLLOW THE RENAMED OBJECTS.
2657426628
// `PermissionSetSchema.fields` is a record keyed `<entity_name>.<field_name>`
2657526629
// (`packages/spec/src/security/permission.zod.ts`), and the security
@@ -26738,7 +26792,21 @@ export class ObjectStackProtocolImplementation implements
2673826792
continue;
2673926793
}
2674026794
}
26741-
const rewritten = deepRewrite(item);
26795+
const collisions: ReferenceCollision[] = [];
26796+
const rewritten = deepRewrite(item, collisions);
26797+
if (collisions.length > 0) {
26798+
const noun = singular === 'permission'
26799+
? 'Permission set'
26800+
: `${singular.charAt(0).toUpperCase()}${singular.slice(1)}`;
26801+
const more = collisions.length - 1;
26802+
failed.push({
26803+
type: row.type,
26804+
name: row.name,
26805+
error: `${noun} '${row.name}' is not copied: ${describeCollision(collisions[0]!)}`
26806+
+ (more > 0 ? ` It has ${more} more such collision${more === 1 ? '' : 's'}.` : ''),
26807+
});
26808+
continue;
26809+
}
2674226810
if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) {
2674326811
rewritten.name = newName;
2674426812
// [ADR-0086 D3] A permission set that declares its SOURCE

0 commit comments

Comments
 (0)