From 340bd51acd46bde51e77baf9150d8be7d75f413c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:26:22 +0000 Subject: [PATCH 1/8] feat(spec): the unmanaged import request declares an optional targetNamespace, and its answer the source namespace of a renamed copy ImportUnmanagedPackageRequestSchema gains `targetNamespace` (optional): the namespace the copy is renamed into. ImportUnmanagedPackageResponseSchema's `namespace` describes the request's target when it named one, and an optional `sourceNamespace` records the namespace a renamed copy's names came from. Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H Co-authored-by: Claude --- packages/spec/src/api/package-lifecycle.zod.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index d66774d65b2..9aae88c1183 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -405,6 +405,12 @@ export const ImportUnmanagedPackageRequestSchema = lazySchema(() => z.object({ targetName: z.string().optional().describe( 'Display name of the new base. Defaults to the source package\'s `name`.', ), + targetNamespace: z.string().optional().describe( + 'The namespace the copy is renamed into: the new base\'s `namespace`, and the prefix every copied object name ' + + 'takes in place of the source\'s, with every reference to a renamed name rewritten (the rename ' + + '`duplicatePackage` applies). Judged by `manifest.namespace`\'s own declaration; a platform-reserved ' + + 'namespace (`base`, `system`, `sys`) is refused. Absent, the copy keeps the source package\'s namespace.', + ), }).strict()); /** @@ -433,7 +439,12 @@ export const ImportUnmanagedPackageResponseSchema = lazySchema(() => z.object({ 'The new writable base that owns every copied item. Nothing is registered under the source id.', ), namespace: z.string().describe( - 'The base\'s namespace — the SOURCE package\'s, kept so every copied object name stays valid.', + 'The base\'s namespace — the request\'s `targetNamespace` when it named one, else the SOURCE package\'s, ' + + 'kept so every copied object name stays valid.', + ), + sourceNamespace: z.string().optional().describe( + 'The SOURCE package\'s namespace, present when the copy was renamed into `namespace` — the provenance of ' + + 'every copied name. Absent when the copy kept the source\'s namespace.', ), copiedCount: z.number().describe('How many items were copied.'), copied: z.array(z.object({ From b45f2f290680979f3963befee11541382bc4e0f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:26:32 +0000 Subject: [PATCH 2/8] feat(metadata-protocol,cloud-connection): an unmanaged install can take a target namespace, renamed by the duplicate's own rename engine The rename logic of duplicatePackage moves whole into one pure engine, package-namespace-rename.ts, which the duplicate now calls. The unmanaged import's targetNamespace calls the same engine after the view containers are expanded: every copied object name takes the target namespace, every reference follows, translations are named for it, and every refusal the rename can meet is raised before the base exists. The install-local door passes targetNamespace to an unmanaged install, refuses it beside a managed one, and records the source namespace in installedFrom. Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 42 +- .../src/package-namespace-rename.ts | 503 ++++++++++++++++++ .../src/package-unmanaged-install.test.ts | 295 ++++++++++ .../src/package-unmanaged-install.ts | 142 ++++- .../src/protocol-package-commits.ts | 436 +++------------ packages/metadata-protocol/src/protocol.ts | 19 +- 6 files changed, 1079 insertions(+), 358 deletions(-) create mode 100644 packages/metadata-protocol/src/package-namespace-rename.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index f5d54196187..b0a60a632ca 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -73,6 +73,14 @@ * payload check, step 1b (package id), the ADR-0131 D3 * walled-catalog refusal and step 1c (protocol handshake), * none of which writes, and before any write. + * [#30794] `targetNamespace` (optional) renames the copy into + * a namespace of its own — the import's rename-on-install: + * every copied object name takes it, every reference follows, + * and the answer's `installedFrom.namespace` records the + * source's. Absent, the copy keeps the package's namespace. + * `targetNamespace` beside a `managed` install (or no `installMode`) + * answers `400 INVALID_REQUEST` before anything is fetched: a + * managed install is never renamed, it keeps its upgrade channel. * A mode the package's `installModes` does not permit answers * `422 INSTALL_MODE_NOT_PERMITTED`, before any write. * Either mode reads the body's `withheldMaps` — the connector header @@ -1800,6 +1808,25 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } const installMode: InstallMode = requestedMode.data; + // 0′. [#30794] ⭐ RENAME-ON-INSTALL IS UNMANAGED ONLY. A `targetNamespace` + // renames the copy an `unmanaged` install writes; a MANAGED install + // is the package itself, registered under the namespace it ships + // and upgraded through it, so it is never renamed — refused here, + // before the cloud round-trip, never ignored. The value itself is + // judged by the import, against the manifest key's own declaration. + const targetNamespace: unknown = body?.targetNamespace; + if (targetNamespace !== undefined && installMode !== 'unmanaged') { + return c.json({ + success: false, + error: { + code: 'INVALID_REQUEST', + message: '`targetNamespace` renames an unmanaged copy, and this install is managed: a managed install ' + + 'is never renamed — it keeps the package\'s namespace and its upgrade channel. Install with ' + + '`installMode: \'unmanaged\'` to take a copy under a namespace of its own, or omit `targetNamespace`.', + }, + }, 400); + } + // ── Offline path: an inline manifest was supplied (file import). ── // Bypass the cloud-fetch entirely; no OS_CLOUD_URL required. const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null; @@ -2192,6 +2219,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { userId, withheldMaps, protocolCheckSkipped, + ...(targetNamespace !== undefined ? { targetNamespace } : {}), }); } @@ -3944,9 +3972,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { withheldMaps: readonly WithheldMap[]; /** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */ protocolCheckSkipped: ProtocolCheckSkipped | undefined; + /** [#30794] The namespace the copy is renamed into, as the request wrote it; the import judges it. */ + targetNamespace?: unknown; }, ): Promise => { - const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args; + const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped, targetNamespace } = args; let protocol: UnmanagedImporter | undefined; try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') { @@ -3966,6 +3996,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { try { result = await protocol.importUnmanagedPackage({ manifest: packageBody, + ...(targetNamespace !== undefined ? { targetNamespace } : {}), ...(userId ? { actor: userId } : {}), } as Parameters>[0]); } catch (err: any) { @@ -3996,13 +4027,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, resolved.status as any); } + // [#30794] A renamed copy's provenance carries the namespace its names + // came from beside the package and version: `installedFrom.namespace` + // → the answer's `namespace` is the mapping the rename applied. + // Omitted when the copy kept the source's namespace, so that answer's + // key set is unchanged. const installedFrom = { packageId: result.sourcePackageId, ...(result.sourceVersion !== undefined ? { version: result.sourceVersion } : {}), + ...(result.sourceNamespace !== undefined ? { namespace: result.sourceNamespace } : {}), }; ctx.logger?.info?.( `[MarketplaceInstallLocal] installed ${manifestId} unmanaged: ${result.copiedCount} item(s) copied into the ` - + `environment base '${result.targetPackageId}' (namespace '${result.namespace}'), installed from ` + + `environment base '${result.targetPackageId}' (namespace '${result.namespace}'` + + `${result.sourceNamespace !== undefined ? `, renamed from '${result.sourceNamespace}'` : ''}), installed from ` + `${installedFrom.packageId}${installedFrom.version ? `@${installedFrom.version}` : ''}` + `${userId ? ` by ${userId}` : ''}. Nothing is registered under '${manifestId}'; the copy follows no upgrade.`, ); diff --git a/packages/metadata-protocol/src/package-namespace-rename.ts b/packages/metadata-protocol/src/package-namespace-rename.ts new file mode 100644 index 00000000000..29db8f693b2 --- /dev/null +++ b/packages/metadata-protocol/src/package-namespace-rename.ts @@ -0,0 +1,503 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0070 D4, #30794] ⭐ THE ONE RENAME ENGINE: a package's items, re-namespaced + * from the source namespace into a target namespace, as pure functions over + * item bodies — no store, no host, no write. + * + * Two doors copy a package under a namespace of its own, and both call this: + * + * - `duplicatePackage` (ADR-0070 D4), over the source base's stored rows; + * - the `unmanaged` install's copy-once import (ADR-0131 D6, `targetNamespace`), + * over the package body it is handed, after the view containers are + * expanded into the names they serve (`package-unmanaged-install.ts`). + * + * ⛔ There is no second copy of these rules. The rename logic below was the body + * of `duplicatePackage` (fixes #19417, #19577, #23510, #24547, and the objectql + * `protocol-duplicate-package-*` battery); it moved here whole, and the + * duplicate now calls it. The same package sent through either door with one + * target namespace yields identical items — the parity the objectql battery + * pins. + * + * What a caller does around it, and what this module never does: + * - it hands every item it will copy ({@link NamespaceRenameItem}), its body + * parsed (`undefined` for a body that does not parse — the caller reports + * that one at its own name); + * - it asks {@link NamespaceRename.objectRefusal} before it writes an object, + * {@link NamespaceRename.copyName} for the name each item is written under, + * and {@link NamespaceRename.rewrite} for the body, after converting the + * body to today's protocol; + * - it delivers each refusal its own way: the duplicate is per-item + * best-effort (`failed[]`), the import is all or nothing (one refusal + * before the base exists). + */ + +import { SysMetadataObject } from '@objectstack/metadata-core'; +import { METADATA_ITEM_NAME_PATTERN, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { REFERENCE_SITES } from './reference-sites.js'; + +/** One item a copy writes, as the rename engine reads it. */ +export interface NamespaceRenameItem { + /** The type as the caller holds it (a stored row's spelling, or the canonical singular). */ + type: string; + /** The item's name in the SOURCE package. */ + name: string; + /** The parsed body; `undefined` when it does not parse. */ + body: unknown; +} + +/** What the engine is told about the copy. */ +export interface NamespaceRenameInput { + /** The source package's id, named by the refusals. */ + sourcePackageId: string; + /** The source's namespace; `''` when the source declares none and none derives. */ + sourceNamespace: string; + /** The namespace every copied object name takes. Already judged by the caller's declaration. */ + targetNamespace: string; + /** Every item the copy writes. */ + items: ReadonlyArray; + /** The door's verb, for the remedy every refusal ends with ("then duplicate again"). */ + retryVerb: 'duplicate' | 'install'; +} + +/** One body, re-namespaced — or the reason it cannot be. */ +export type NamespaceRewriteOutcome = { item: unknown } | { refusal: string }; + +/** The rename a copy applies, planned once over every item. */ +export interface NamespaceRename { + readonly sourceNamespace: string; + readonly targetNamespace: string; + /** Every object the copy renames: source name → copy name. One-to-one. */ + readonly objectRenames: ReadonlyMap; + /** + * Why an object cannot be copied — its copy name is claimed by another + * object's copy or by an object the source holds — or `undefined`. + */ + objectRefusal(name: string): string | undefined; + /** The name an item is copied under. */ + copyName(type: string, name: string): string; + /** + * Why an item's copy name cannot be stored — it breaks the item-name + * grammar, or the storage identifier limit (an object's name is its table + * name) — or `undefined`. Asked by the unmanaged import, which refuses + * before any write; ⛔ not by the duplicate, whose door this card does not + * narrow. + */ + copyNameRefusal(type: string, name: string): string | undefined; + /** + * The body as the copy writes it: every name the copy renames rewritten, + * `name` set to {@link copyName}, a permission set's field-rule keys + * following the renamed objects — or the collision that refuses it. + * `item` is the body converted to today's protocol; `name` is its SOURCE + * name. + */ + rewrite(type: string, name: string, item: unknown): NamespaceRewriteOutcome; +} + +/** + * The platform-reserved namespaces (`ManifestSchema.namespace`'s rule: + * "Platform-reserved namespaces: "base", "system", "sys""), which many + * packages share, so none can carry one copy's names. Asked by the unmanaged + * import's `targetNamespace`. + */ +export const PLATFORM_RESERVED_NAMESPACES: ReadonlySet = new Set(['base', 'system', 'sys']); + +/** + * [#30794] The storage identifier limit an OBJECT name meets: an object name is + * its table name (Prime Directive #6), and Postgres keeps 63 bytes of an + * identifier (MySQL 64), so a longer copy name is cut there and two copied + * tables can land on one. Object names are ASCII (`/^[a-z_][a-z0-9_]*$/`), so + * characters are bytes. + */ +export const OBJECT_TABLE_IDENTIFIER_LIMIT = 63; + +/** + * [#30794] The storage limit EVERY item name meets: the width of the + * `sys_metadata.name` column that stores it, read off its declaration — the + * column the record validator refuses a longer value at. + */ +export const METADATA_ITEM_NAME_STORAGE_LIMIT: number = + (SysMetadataObject.fields as Record).name?.maxLength ?? 255; + +/** A `view` row whose name is qualified `.` (`ViewItemNameSchema`). */ +function isQualifiedViewName(type: string, name: unknown): name is string { + return (PLURAL_TO_SINGULAR[String(type)] ?? String(type)) === 'view' + && typeof name === 'string' && name.includes('.'); +} + +/** A body's `fields` record keys, when it has one. */ +function fieldKeysOf(body: unknown): string[] { + const fields = (body as { fields?: unknown } | null | undefined)?.fields; + return fields && typeof fields === 'object' && !Array.isArray(fields) ? Object.keys(fields) : []; +} + +/** + * Plan the rename of one package into `targetNamespace`, over every item the + * copy writes. Pure: the same input answers the same rename. + */ +export function planNamespaceRename(input: NamespaceRenameInput): NamespaceRename { + const { sourcePackageId, sourceNamespace: sourceNs, targetNamespace: targetNs, items } = input; + const again = `then ${input.retryVerb} again`; + + // 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)}` + : name; + + // ⭐ EVERY OBJECT GETS A NAME IN THE TARGET NAMESPACE (ADR-0070 D4, read + // with ADR-0129 D2). An object name IS its table name and its registry + // key, so a copy that keeps the source's name is a second claim on the + // source's object: before PR #23510 it stored a target row the registry + // refused, over the source's table, and after it the copy failed with + // the ADR-0029 D9.9 ownership prescription, which names no way out for + // a duplicate. ADR-0129 D2 says what the name is: every object a + // package defines is `${namespace}_${shortName}`, and the prefix rule's + // own prescription for a name that lacks the prefix + // (`validateObjectNamespacePrefix`) is "rename it to + // `${namespace}_${name}`". The copy is a NEW object, so that rename + // costs nothing here — while renaming the SOURCE object would move its + // table. A prefixed name swaps the source prefix for the target's; + // a name WITHOUT the source prefix (a direct-publish save stored + // before that door read the prefix rule — #24547 — or a package that + // had no namespace) takes the target prefix in front of its whole name. + // + // ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by + // the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name + // `iojn2_ticket`) or by an object the source package holds — the object + // is refused before anything is written for it, at its own name, with + // the rename that clears it. A second invented name (a numeric suffix, a + // third prefix) would be a name nobody wrote. + const objectNames = new Set( + items.filter((item) => item?.type === 'object' && typeof item.name === 'string').map((item) => item.name), + ); + const renameMap = new Map(); + const prefixed: string[] = []; + const unprefixed: string[] = []; + for (const name of objectNames) { + if (sourceNs && name.startsWith(`${sourceNs}_`)) prefixed.push(name); + else unprefixed.push(name); + } + for (const name of prefixed) { + const nn = renameName(name); + if (nn !== name) renameMap.set(name, nn); + } + // Every name a prefixed object's copy takes (itself, when the target + // namespace IS the source's), and every name the source holds. + const claimedBy = new Map(prefixed.map((name) => [renameName(name), name])); + const refusedObjects = new Map(); + for (const name of unprefixed) { + const nn = `${targetNs}_${name}`; + const holder = claimedBy.get(nn) ?? (objectNames.has(nn) ? nn : undefined); + if (holder !== undefined) { + const lacks = sourceNs + ? `lacks the '${sourceNs}_' prefix of package '${sourcePackageId}' (ADR-0129 D2: ` + + 'every object a package defines carries its namespace as a name prefix)' + : `carries no namespace prefix (package '${sourcePackageId}' declares no namespace)`; + const taken = holder === nn + ? `'${nn}' is an object package '${sourcePackageId}' already holds` + : `'${nn}' is already the copy name of object '${holder}'`; + refusedObjects.set( + name, + `Object '${name}' is not copied: its name ${lacks}, so its copy is named '${nn}' in ` + + `namespace '${targetNs}', and ${taken}. Rename '${name}' on the source to ` + + `${sourceNs ? `a '${sourceNs}_' name` : 'a name'} no other object in the package uses, ` + + `${again}.`, + ); + continue; + } + renameMap.set(name, nn); + claimedBy.set(nn, name); + } + + // Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts + // `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. + // + // ⭐ A PREFIXED NAME THAT IS ALSO A FIELD KEY IS NOT DISTINCTIVE. When a + // package object declares a field keyed like a prefixed package object + // (`fields.iojn_customer` beside the object `iojn_customer`), a free-text + // value spelled that way may name the field — a list view's `columns`, + // a form's `sections[].fields` — and the field keeps its key. Renaming + // it as the object copied a column or form field the copy does not + // have. Such a name is treated as a bare name is: rewritten only at an + // object-reference property (below), never in free text. + const fieldKeys = new Set(); + for (const item of items) { + if (item?.type !== 'object') continue; + for (const key of fieldKeysOf(item.body)) fieldKeys.add(key); + } + const olds = prefixed + .filter((name) => renameMap.has(name) && !fieldKeys.has(name)) + .sort((a, b) => b.length - a.length); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = olds.length + ? new RegExp(`(?.` (`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` (`.`) 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 qualifiedViewCopyName = (name: string): string => { + const dot = name.indexOf('.'); + const renamed = renameMap.get(name.slice(0, dot)); + return renamed !== undefined ? `${renamed}${name.slice(dot)}` : name; + }; + const viewItemRenames = new Map(); + for (const item of items) { + if (!isQualifiedViewName(item?.type, item?.name)) continue; + const renamed = qualifiedViewCopyName(item.name); + if (renamed !== item.name) viewItemRenames.set(item.name, renamed); + } + // ⭐ 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" + // into "A support iojn2_ticket raised …", and the same match renames the + // value of a lookup FIELD called `ticket` (`fields.ticket.name`). So a + // value is rewritten only where it sits under a property that names an + // object — the derived site index (`REFERENCE_SITES`, target `object`: + // `reference`, `object`, `objectName`, `objects`, …), read by property + // wherever it occurs in any item, because flow node configs (`config. + // objectName`) are declared by node descriptors the index does not + // walk — and only when it equals a renamed name exactly, in the four + // shapes a name arrives in there: a string, an array element, a record + // KEY (`permission.objects` is keyed by object name) or a `.name`. The + // record key holds for prefixed names too: a copied permission set + // otherwise kept `objects.`, a grant on the SOURCE. + // + // ⛔ REFUSED, NEVER MERGED: at a reference site, a renamed name can + // meet a name the item already holds there — the source also names + // the object the copy is about to be called (`dupdst_customer`, an + // object outside the package or not yet created) beside its own + // (`dupsrc_customer`). Two record KEYS that land on one key keep only + // the last entry: a permission set's `objects` dropped a grant (and + // the copied object could gain edit and delete its source never gave + // it), a translation's `objects` dropped a label. Two array ELEMENTS + // that land on one name bind it twice: a hook's handler ran twice per + // event on the copied object. So every such meeting is recorded in + // `collisions` — the path, the name that moved, the name it met — and + // the item is refused before anything is written for it. `renameMap` is + // one-to-one, so a meeting is always a renamed name and a name this copy + // does not rename; an element the source itself repeats is copied as + // stored. + const objectReferenceProperties = new Set( + (REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property), + ); + type ReferenceCollision = { at: string; shape: 'key' | 'entry'; moved: string; met: string }; + const deepRewrite = (v: any, collisions: ReferenceCollision[], atReference = false, at = ''): any => { + 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)) { + const out = v.map((element, i) => deepRewrite(element, collisions, atReference, `${at}[${i}]`)); + if (atReference) { + const firstAt = new Map(); + out.forEach((element, i) => { + if (typeof element !== 'string') return; + const j = firstAt.get(element); + if (j === undefined) { + firstAt.set(element, i); + } else if (v[j] !== v[i]) { + collisions.push({ at, shape: 'entry', moved: v[i] !== element ? v[i] : v[j], met: element }); + } + }); + } + return out; + } + if (v && typeof v === 'object') { + const o: any = {}; + const from = new Map(); + for (const [k, val] of Object.entries(v)) { + const key = atReference ? (renameMap.get(k) ?? k) : k; + const other = from.get(key); + if (other !== undefined) { + collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key }); + } + from.set(key, k); + 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; + } + return v; + }; + const describeCollision = (c: ReferenceCollision): string => + `its \`${c.at}\` ${c.shape} '${c.moved}' follows the copied object to '${c.met}', ` + + `${c.shape === 'key' ? 'a key' : 'an entry'} it already holds there, which this copy does not rename. ` + + (c.shape === 'key' + ? 'A record keeps one entry per key, so the copy would drop one of the two. ' + : `The copy would name '${c.met}' twice. `) + + `Remove '${c.moved}' or '${c.met}' on the source, ${again}.`; + // ⭐ A COPIED PERMISSION SET'S FIELD RULES FOLLOW THE RENAMED OBJECTS. + // `PermissionSetSchema.fields` is a record keyed `.` + // (`packages/spec/src/security/permission.zod.ts`), and the security + // plugin reads a set's rules for an object by the key prefix + // `.` (`PermissionEvaluator.getFieldPermissions`). Left as + // stored, the copy's rules govern the SOURCE's object and the + // duplicate's object gets none: a field the source set hides is + // readable on the copy for whoever holds the copied set. + // + // `deepRewrite` cannot carry it: the derived site index reads + // `permission.fields` as a site naming `field` items (its property + // spells that type), never an object, and has no arm for a composite + // key — and widening the index would also change the "Used by" answer + // it feeds (`findReferencesToMeta`). So this step is keyed on the + // declared shape — the `fields` record of a copied `permission` item — + // never on a property name, which would also catch an object's own + // `fields` (keyed by field name). + // + // The split: object and field names are both `^[a-z_][a-z0-9_]*$`, so + // a key's object part is everything before its FIRST `.`. It is + // rewritten only when it equals a renamed object name exactly, and the + // field part is never read — `dupsrc_customer.dupsrc_customer` becomes + // `dupdst_customer.dupsrc_customer`. A key naming an object outside the + // package keeps its name. + // + // ⛔ REFUSED, NEVER MERGED: when a renamed key equals a key the set + // already holds (the source also governs an object, outside the + // package, named like the copy), keeping either rule would silently + // drop the other, so the set is refused with both keys. + // `Object.fromEntries` defines own properties, so no key can land on + // the prototype. + const rewriteFieldRuleKeys = (fields: Record): { fields: Record } | { collision: string } => { + const out = new Map(); + const from = new Map(); + for (const [key, rule] of Object.entries(fields)) { + const dot = key.indexOf('.'); + const renamed = dot > 0 ? renameMap.get(key.slice(0, dot)) : undefined; + const next = renamed === undefined ? key : `${renamed}${key.slice(dot)}`; + const other = from.get(next); + if (other !== undefined) { + const moved = next === key ? other : key; + return { + collision: `its field rule '${moved}' follows object '${moved.slice(0, moved.indexOf('.'))}' ` + + `to '${next}', a key the set already holds for object ` + + `'${next.slice(0, next.indexOf('.'))}', which this copy does not rename. ` + + `Remove '${moved}' or '${next}' on the source, ${again}.`, + }; + } + out.set(next, rule); + from.set(next, key); + } + return { fields: Object.fromEntries(out) }; + }; + + const copyName = (type: string, name: string): string => + type === 'object' + ? (renameMap.get(name) ?? name) + : isQualifiedViewName(type, name) + ? qualifiedViewCopyName(name) + : renameName(name); + + const nounOf = (type: string): string => { + const singular = PLURAL_TO_SINGULAR[String(type)] ?? String(type); + return singular === 'permission' + ? 'Permission set' + : `${singular.charAt(0).toUpperCase()}${singular.slice(1)}`; + }; + + return { + sourceNamespace: sourceNs, + targetNamespace: targetNs, + objectRenames: renameMap, + objectRefusal: (name) => refusedObjects.get(name), + copyName, + copyNameRefusal(type, name) { + const next = copyName(type, name); + if (next === name) return undefined; + const noun = nounOf(type); + const remedy = `Name a shorter \`targetNamespace\`, or shorten '${name}' on the source, ${again}.`; + if (!METADATA_ITEM_NAME_PATTERN.test(next)) { + return `${noun} '${name}' is not copied: its copy name '${next}' in namespace '${targetNs}' is not a ` + + 'legal metadata item name (lowercase snake_case segments, joined by dots). ' + + `Rename '${name}' on the source, ${again}.`; + } + if (type === 'object' && next.length > OBJECT_TABLE_IDENTIFIER_LIMIT) { + return `${noun} '${name}' is not copied: its copy name '${next}' in namespace '${targetNs}' is ` + + `${next.length} characters, over the ${OBJECT_TABLE_IDENTIFIER_LIMIT}-character storage ` + + 'identifier limit of the table an object name names. ' + remedy; + } + if (next.length > METADATA_ITEM_NAME_STORAGE_LIMIT) { + return `${noun} '${name}' is not copied: its copy name '${next}' in namespace '${targetNs}' is ` + + `${next.length} characters, over the ${METADATA_ITEM_NAME_STORAGE_LIMIT}-character limit of ` + + 'the column that stores an item name. ' + remedy; + } + return undefined; + }, + rewrite(type, name, item) { + const singular = PLURAL_TO_SINGULAR[String(type)] ?? String(type); + const collisions: ReferenceCollision[] = []; + const rewritten = deepRewrite(item, collisions); + if (collisions.length > 0) { + const more = collisions.length - 1; + return { + refusal: `${nounOf(type)} '${name}' is not copied: ${describeCollision(collisions[0]!)}` + + (more > 0 ? ` It has ${more} more such collision${more === 1 ? '' : 's'}.` : ''), + }; + } + if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) { + rewritten.name = copyName(type, name); + if (singular === 'permission' && rewritten.fields + && typeof rewritten.fields === 'object' && !Array.isArray(rewritten.fields)) { + const result = rewriteFieldRuleKeys(rewritten.fields); + if ('collision' in result) { + return { refusal: `Permission set '${name}' is not copied: ${result.collision}` }; + } + rewritten.fields = result.fields; + } + } + return { item: rewritten }; + }, + }; +} diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index 5c8a34122d7..86e80d96b2d 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -876,3 +876,298 @@ describe('importUnmanagedPackage — the verb', () => { expect(ddl).toEqual(expect.arrayContaining(['sync crm_lead', 'drop crm_lead'])); }); }); + +/** + * [#30794] Rename-on-install: a `targetNamespace` copies the package under a + * namespace of its own, through the duplicate's own rename engine + * (`package-namespace-rename.ts`), after the view containers are expanded. + */ +function renamableBody(extra: Record = {}): Record { + return crmBody({ + objects: [ + { + name: 'crm_account', + label: 'Account', + sharingModel: 'private', + description: 'Every crm_lead belongs to one.', + fields: { title: { type: 'text', label: 'Title' } }, + }, + { + name: 'crm_lead', + label: 'Lead', + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + account: { type: 'lookup', label: 'Account', reference: 'crm_account' }, + }, + }, + ], + views: [ + { + name: 'crm_lead_open', + label: 'Open leads', + object: 'crm_lead', + viewKind: 'list', + columns: [{ field: 'title', label: 'Title' }], + }, + // A container: named by the object it binds, expanding into `crm_account.` names. + { object: 'crm_account', list: { label: 'All', columns: [{ field: 'title' }] } }, + ], + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' }, crm_account: { label: '客户' } } } }], + ...extra, + }); +} + +describe('planUnmanagedImport — `targetNamespace`, the rename-on-install (#30794)', () => { + it('renames every object, view item, container and its expansion, and translation into the target namespace', () => { + const p = plan(renamableBody(), { targetNamespace: 'acme' }); + expect(p.namespace).toBe('acme'); + expect(p.sourceNamespace).toBe('crm'); + expect(p.baseManifest).toMatchObject({ namespace: 'acme' }); + expect(p.items.map((i) => `${i.type}/${i.sourceName} -> ${i.name}`)).toEqual([ + 'object/crm_account -> acme_account', + 'object/crm_lead -> acme_lead', + 'view/crm_lead_open -> acme_lead_open', + 'view/crm_account -> acme_account', + 'translation/crm_zh_cn -> acme_zh_cn', + ]); + // The translation item is named for the target namespace, as an import into it names it. + expect(p.items.find((i) => i.type === 'translation')!.name).toBe(unmanagedTranslationItemName('acme', 'zh-CN')); + // The container's expansion takes its object's copy name: what the save door expands the renamed container into. + const container = p.items.find((i) => i.type === 'view' && i.name === 'acme_account')!; + expect(container.expands.length).toBeGreaterThan(0); + expect(container.expands.every((name) => name.startsWith('acme_account.'))).toBe(true); + // Bodies are rewritten by the verb after conversion; the plan carries the engine for it. + expect(p.rename?.objectRenames).toEqual(new Map([['crm_account', 'acme_account'], ['crm_lead', 'acme_lead']])); + }); + + it('absent, the copy keeps the source namespace exactly as before; the source\'s own namespace named is no rename', () => { + for (const extra of [{}, { targetNamespace: 'crm' }]) { + const p = plan(renamableBody(), extra); + expect(p.namespace).toBe('crm'); + expect(p.sourceNamespace).toBe('crm'); + expect(p).not.toHaveProperty('rename'); + expect(p.items.every((i) => i.sourceName === undefined)).toBe(true); + expect(p.items.map((i) => i.name)).toEqual(['crm_account', 'crm_lead', 'crm_lead_open', 'crm_account', 'crm_zh_cn']); + } + }); + + it('refuses a value the namespace declaration refuses — 400, naming `targetNamespace`, never sanitised', () => { + for (const targetNamespace of ['Acme', 'a', 'my-ns', '1acme', 'acme_namespace_too_long']) { + const err = (() => { try { plan(renamableBody(), { targetNamespace }); } catch (e) { return e as any; } })(); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'VALIDATION_ERROR']); + expect(err.message).toContain(`Invalid package namespace '${targetNamespace}' on \`targetNamespace\``); + } + }); + + it('refuses a platform-reserved namespace — `base`, `system`, `sys` — 400', () => { + for (const targetNamespace of ['base', 'system', 'sys']) { + const err = (() => { try { plan(renamableBody(), { targetNamespace }); } catch (e) { return e as any; } })(); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'VALIDATION_ERROR']); + expect(err.message).toContain(`\`targetNamespace\` '${targetNamespace}' is a platform-reserved namespace`); + } + }); + + it('refuses an object whose copy name another object\'s copy claims — INVALID_REQUEST / 400, naming it', () => { + // `lead` lacks the prefix, so its copy is `acme_lead`, which `crm_lead`'s copy already takes. + const body = renamableBody(); + (body.objects as unknown[]).push({ name: 'lead', label: 'Bare', sharingModel: 'private', fields: {} }); + const err = (() => { try { plan(body, { targetNamespace: 'acme' }); } catch (e) { return e as any; } })(); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'INVALID_REQUEST']); + expect(err.details.targetNamespace).toBe('acme'); + expect(err.details.refused.map((r: any) => `${r.type}/${r.name}`)).toEqual(['object/lead']); + expect(err.details.refused[0].reason).toContain("'acme_lead' is already the copy name of object 'crm_lead'"); + expect(err.details.refused[0].reason).toContain('then install again'); + }); + + it('refuses an object whose copy name exceeds the storage identifier limit of its table — naming it', () => { + // 59 characters under `crm`; 75 under the 19-character `acme_long_namespace`. + const long = `crm_${'x'.repeat(55)}`; + const body = renamableBody(); + (body.objects as unknown[]).push({ name: long, label: 'Long', sharingModel: 'private', fields: {} }); + expect(plan(body).items.some((i) => i.name === long)).toBe(true); + const err = (() => { try { plan(body, { targetNamespace: 'acme_long_namespace' }); } catch (e) { return e as any; } })(); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'INVALID_REQUEST']); + expect(err.details.refused.map((r: any) => `${r.type}/${r.name}`)).toEqual([`object/${long}`]); + expect(err.details.refused[0].reason).toContain('63-character storage identifier limit'); + }); + + it('refuses an item whose copy name exceeds the column that stores an item name — naming it', () => { + const key = 'k'.repeat(240); + const body = renamableBody({ + views: [{ name: `crm_lead.${key}`, label: 'Long', object: 'crm_lead', viewKind: 'list', columns: [{ field: 'title' }] }], + }); + expect(`crm_lead.${key}`.length).toBeLessThanOrEqual(255); + const err = (() => { try { plan(body, { targetNamespace: 'acme_long_namespace' }); } catch (e) { return e as any; } })(); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'INVALID_REQUEST']); + expect(err.details.refused.map((r: any) => `${r.type}/${r.name}`)).toEqual([`view/crm_lead.${key}`]); + expect(err.details.refused[0].reason).toContain('255-character limit of the column that stores an item name'); + }); + + it('refuses a container whose expanded copy name exceeds that column — naming the expanded view', () => { + const key = 'k'.repeat(240); + const body = renamableBody({ + views: [{ object: 'crm_lead', listViews: { [key]: { label: 'Long', columns: [{ field: 'title' }] } } }], + }); + const container = plan(body).items.find((i) => i.type === 'view')!; + expect(container.expands).toContain(`crm_lead.${key}`); + const err = (() => { try { plan(body, { targetNamespace: 'acme_long_namespace' }); } catch (e) { return e as any; } })(); + expect(err.details.refused.map((r: any) => `${r.type}/${r.name}`)).toEqual([`view/crm_lead.${key}`]); + }); +}); + +describe('importUnmanagedPackage — `targetNamespace` (#30794)', () => { + it('copies under the target namespace: every name, every reference, the base, and the source namespace on the answer', async () => { + const { protocol, table, packages } = makeProtocol(); + const result = await protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 'acme' }); + + expect(ImportUnmanagedPackageResponseSchema.parse(result)).toEqual(result); + expect(result).toMatchObject({ namespace: 'acme', sourceNamespace: 'crm', targetPackageId: 'com.example.crm-copy' }); + const active = table('sys_metadata').filter((r) => r.state === 'active'); + expect(active.map((r) => `${r.type}/${r.name}@${r.package_id}`).sort()).toEqual([ + 'object/acme_account@com.example.crm-copy', + 'object/acme_lead@com.example.crm-copy', + 'translation/acme_zh_cn@com.example.crm-copy', + 'view/acme_account@com.example.crm-copy', + 'view/acme_lead_open@com.example.crm-copy', + ]); + const body = (type: string, name: string) => JSON.parse(active.find((r) => r.type === type && r.name === name)!.metadata); + expect(body('object', 'acme_lead').fields.account.reference).toBe('acme_account'); + // Free text names the renamed object as a whole identifier. + expect(body('object', 'acme_account').description).toBe('Every acme_lead belongs to one.'); + expect(body('view', 'acme_lead_open')).toMatchObject({ name: 'acme_lead_open', object: 'acme_lead' }); + // The translation's object keys follow the renamed objects. + expect(body('translation', 'acme_zh_cn')).toMatchObject({ + name: 'acme_zh_cn', locale: 'zh-CN', objects: { acme_lead: { label: '线索' }, acme_account: { label: '客户' } }, + }); + expect(packages.get('com.example.crm-copy')?.manifest).toMatchObject({ namespace: 'acme' }); + // Nothing is left under a source name. + expect(active.some((r) => String(r.name).startsWith('crm_'))).toBe(false); + }); + + it('the copy kept under the source namespace answers no `sourceNamespace` — the answer\'s key set unchanged', async () => { + const { protocol } = makeProtocol(); + const result = await protocol.importUnmanagedPackage({ manifest: renamableBody() }); + expect(result.namespace).toBe('crm'); + expect(result).not.toHaveProperty('sourceNamespace'); + }); + + it('judges collisions on the RENAMED names: a row holding the copy name refuses (409); the source name does not', async () => { + const held = { id: 'held', type: 'object', organization_id: null, package_id: 'com.example.studio', state: 'active', metadata: '{}' }; + { + const { protocol, table, packages } = makeProtocol(); + table('sys_metadata').push({ ...held, name: 'acme_lead' }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 'acme' })); + expect([resolveThrownHttpError(err).status, resolveThrownHttpError(err).code]).toEqual([409, 'RESOURCE_CONFLICT']); + expect(err.details.collisions).toEqual([ + { type: 'object', name: 'acme_lead', heldBy: "an environment item of package 'com.example.studio'" }, + ]); + expect(packages.size).toBe(0); + } + { + // CONTROL: the source's own name, held, is no collision of the renamed copy. + const { protocol, table } = makeProtocol(); + table('sys_metadata').push({ ...held, name: 'crm_lead' }); + const result = await protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 'acme' }); + expect(result.copied).toContainEqual({ type: 'object', name: 'acme_lead' }); + } + }); + + it('refuses an existing target base whose namespace differs from `targetNamespace` — 409, nothing written', async () => { + const { protocol, engine, writes } = makeProtocol(); + engine.registry.installPackage({ id: 'com.example.mine', name: 'Mine', namespace: 'mine' }, 'code'); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + manifest: renamableBody(), targetPackageId: 'com.example.mine', targetNamespace: 'acme', + })); + expect([resolveThrownHttpError(err).status, resolveThrownHttpError(err).code]).toEqual([409, 'RESOURCE_CONFLICT']); + expect(err.details).toEqual({ targetPackageId: 'com.example.mine' }); + expect(writes).toEqual([]); + }); + + it('refuses a target namespace another package owns — 409, naming `targetNamespace`, nothing written', async () => { + const { protocol, writes, packages } = makeProtocol({ owners: { acme: ['com.example.acme'] } }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 'acme' })); + expect([resolveThrownHttpError(err).status, resolveThrownHttpError(err).code]).toEqual([409, 'RESOURCE_CONFLICT']); + expect(err.details).toEqual({ namespace: 'acme', owners: ['com.example.acme'] }); + expect(err.message).toContain('named by `targetNamespace`'); + expect(packages.size).toBe(0); + expect(writes).toEqual([]); + }); + + it('admits the copy beside the package that owns the SOURCE namespace — a managed install of it included', async () => { + const { protocol } = makeProtocol({ owners: { crm: ['com.example.crm'] } }); + const result = await protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 'acme' }); + expect(result).toMatchObject({ namespace: 'acme', sourceNamespace: 'crm' }); + }); + + it('refuses a reference collision the converted body meets — INVALID_REQUEST / 400, before any write', async () => { + const { protocol, writes, packages } = makeProtocol(); + // The source set already grants on `acme_lead` (an object outside the package), and `crm_lead` moves onto it. + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + manifest: renamableBody({ + permissions: [{ name: 'crm_ops', label: 'Ops', objects: { crm_lead: { allowRead: true }, acme_lead: { allowRead: true } } }], + }), + targetNamespace: 'acme', + })); + const envelope = resolveThrownHttpError(err); + expect([envelope.status, envelope.code]).toEqual([400, 'INVALID_REQUEST']); + expect(err.details.refused.map((r: any) => `${r.type}/${r.name}`)).toEqual(['permission/crm_ops']); + expect(err.details.refused[0].reason).toContain("its `objects` key 'crm_lead' follows the copied object to 'acme_lead'"); + expect(writes).toEqual([]); + expect(packages.size).toBe(0); + }); + + it('refuses a `targetNamespace` its published declaration refuses (not a string) — 400, nothing written', async () => { + const { protocol, writes } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: renamableBody(), targetNamespace: 7 })); + expect([resolveThrownHttpError(err).status, resolveThrownHttpError(err).code]).toEqual([400, 'VALIDATION_ERROR']); + expect(writes).toEqual([]); + }); +}); + +describe('the rename parity — one package, one `targetNamespace`, two doors, identical items (#30794)', () => { + /** Every active row of one package: `type/name` → its stored body. */ + const itemsOf = (table: (name: string) => Row[], packageId: string) => Object.fromEntries( + table('sys_metadata') + .filter((r) => r.package_id === packageId && r.state === 'active') + .map((r) => [`${r.type}/${r.name}`, typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata]) + .sort(([a], [b]) => String(a).localeCompare(String(b))), + ); + + it('duplicatePackage of the package\'s base and the unmanaged import into the same namespace store the same items', async () => { + const body = renamableBody({ + actions: [{ name: 'crm_new_lead', label: 'New lead', type: 'form', objectName: 'crm_lead', target: 'crm_lead.entry' }], + }); + + // Door 1: the package as a base (an unmanaged copy keeping its names), then duplicated into `acme`. + const viaDuplicate = makeProtocol(); + await viaDuplicate.protocol.importUnmanagedPackage({ manifest: body, targetPackageId: 'com.example.crm-base' }); + const duplicated = await viaDuplicate.protocol.duplicatePackage({ + sourcePackageId: 'com.example.crm-base', targetPackageId: 'com.example.acme', targetNamespace: 'acme', + }); + expect(duplicated.failed).toEqual([]); + + // Door 2: the same package imported straight into `acme`. + const viaImport = makeProtocol(); + await viaImport.protocol.importUnmanagedPackage({ manifest: body, targetPackageId: 'com.example.acme', targetNamespace: 'acme' }); + + const duplicatedItems = itemsOf(viaDuplicate.table, 'com.example.acme'); + const importedItems = itemsOf(viaImport.table, 'com.example.acme'); + expect(Object.keys(importedItems)).toEqual([ + 'action/acme_new_lead', + 'object/acme_account', + 'object/acme_lead', + 'translation/acme_zh_cn', + 'view/acme_account', + 'view/acme_lead_open', + ]); + expect(importedItems).toEqual(duplicatedItems); + expect(viaImport.packages.get('com.example.acme')?.manifest.namespace) + .toBe(viaDuplicate.packages.get('com.example.acme')?.manifest.namespace); + }); +}); diff --git a/packages/metadata-protocol/src/package-unmanaged-install.ts b/packages/metadata-protocol/src/package-unmanaged-install.ts index 7c4d299da77..ee30c134bc0 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.ts @@ -109,6 +109,7 @@ import { ASSEMBLED_VIEW_ITEMS_KEY, expandViewContainer, isAggregatedViewContaine import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; +import { PLATFORM_RESERVED_NAMESPACES, planNamespaceRename, type NamespaceRename } from './package-namespace-rename.js'; /** * Every collection the import copies: manifest key → metadata type. The @@ -134,14 +135,29 @@ export interface UnmanagedImportItem { body: Record; /** The view names a container expands into (empty for every other item). */ expands: readonly string[]; + /** + * [#30794] The name the PACKAGE declares the item under, when the copy is + * renamed into a `targetNamespace` and `name` is its copy name; absent + * when the copy keeps the source's names. + */ + sourceName?: string; } /** Everything the import needs from the body, judged. */ export interface UnmanagedImportPlan { sourcePackageId: string; sourceVersion?: string; - /** The base's namespace — the source's, declared or derived from its id. */ + /** The base's namespace — the request's `targetNamespace`, else the source's. */ namespace: string; + /** The source's namespace, declared or derived from its id. */ + sourceNamespace: string; + /** + * [#30794] The rename into {@link namespace}, when it differs from the + * source's — the duplicate's own engine (`package-namespace-rename.ts`). + * Every item's `name` and `expands` are already its copy names; the + * caller rewrites each body with it after converting it. + */ + rename?: NamespaceRename; targetPackageId: string; /** The manifest the new base is installed with. */ baseManifest: Record; @@ -345,6 +361,11 @@ export function planUnmanagedImport( options: { targetPackageId?: string; targetName?: string; + /** + * [#30794] The namespace the copy is renamed into; absent keeps the + * source's. Judged here, by the manifest key's own declaration. + */ + targetNamespace?: string; isRuntimeCreatable: (type: string) => boolean; loadOrderOf: (type: string) => number; /** @@ -432,17 +453,48 @@ export function planUnmanagedImport( } if (findings.length > 0) throw unmanagedInstallUncopyable(sourcePackageId, findings); - // The namespace is the SOURCE's (the stage-0 ACCEPT's Q3): every copied - // object name carries its prefix, and the base must own that prefix for - // the save door's namespace rule to admit them. - const namespace = stringKey(manifest, 'namespace') ?? deriveNamespaceFromPackageId(sourcePackageId) ?? undefined; - if (namespace === undefined) { + // The SOURCE's namespace: every name the package declares carries its + // prefix. Without a `targetNamespace` it is the base's too (the stage-0 + // ACCEPT's Q3): the base must own that prefix for the save door's + // namespace rule to admit the copied object names. + const sourceNamespace = stringKey(manifest, 'namespace') ?? deriveNamespaceFromPackageId(sourcePackageId) ?? undefined; + if (sourceNamespace === undefined) { throw invalidRequest( `Cannot determine the namespace of package '${sourcePackageId}': it declares none, and none derives ` + 'from its id. Declare `namespace` on the package — every copied object name carries it.', ); } + // [#30794] ⭐ RENAME-ON-INSTALL, UNMANAGED ONLY. A `targetNamespace` is the + // base's namespace and the prefix every copied object name takes, so it + // is parsed by the manifest key's own declaration + // (`ManifestSchema.shape.namespace`, never a hand-copied regex) and ⛔ + // refused, never sanitised — the rule `duplicatePackage` keeps for its + // own `targetNamespace`. A platform-reserved namespace is shared by many + // packages, so it is refused too. Q3's reason for keeping the source + // namespace is that a copy INTO an existing base breaks that base's + // namespace on export; a copy into a NEW base under a namespace of its + // own breaks nothing, and the renamed copy's names are the copy's own. + const targetNamespace = options.targetNamespace; + if (targetNamespace !== undefined) { + const declared = ManifestSchema.shape.namespace.safeParse(targetNamespace); + if (!declared.success) { + const rule = declared.error.issues[0]?.message ?? 'See `manifest.namespace`'; + throw invalidRequest( + `Invalid package namespace '${targetNamespace}' on \`targetNamespace\`. ${rule}. It becomes the ` + + 'copy\'s `manifest.namespace` and the prefix of every copied object name.', + ); + } + if (PLATFORM_RESERVED_NAMESPACES.has(targetNamespace)) { + throw invalidRequest( + `\`targetNamespace\` '${targetNamespace}' is a platform-reserved namespace ` + + `(${[...PLATFORM_RESERVED_NAMESPACES].map((ns) => `'${ns}'`).join(', ')}), which many packages share, ` + + 'so it cannot carry one copy\'s names. Name a namespace of the copy\'s own.', + ); + } + } + const namespace = targetNamespace ?? sourceNamespace; + // The target: a NEW base whose id differs from the source (Q3). const targetPackageId = options.targetPackageId ?? `${sourcePackageId}-copy`; const declaredTarget = ManifestSchema.shape.id.safeParse(targetPackageId); @@ -462,13 +514,56 @@ export function planUnmanagedImport( // lock dropped listed in write order (the D6 lock-fork ruling, record // 6119681934). After the sort, so the list reads in the order items land. const strippedLocks: UnmanagedImportStrippedLock[] = []; - const items = enumerateItems(manifest, options.loadOrderOf, namespace).map((item) => { + let items = enumerateItems(manifest, options.loadOrderOf, sourceNamespace).map((item) => { const { body, stripped } = stripUnmanagedImportLock( item.type, item.name, item.body, options.protectionDeclarationOf(item.type), ); if (stripped) strippedLocks.push(stripped); return body === item.body ? item : { ...item, body }; }); + + // [#30794] ⭐ THE RENAME RUNS AFTER THE VIEW CONTAINERS ARE EXPANDED, through + // the duplicate's own engine. The items above are named as the package + // declares them — a container under the object it binds, the names it + // expands into listed, each locale's translations under + // `_` — which is the set of rows a stored base + // holds, so the engine renames exactly what `duplicatePackage` renames: + // a container follows its object, every name it expands into takes its + // object's copy name (`.`), and a translation item is + // named `_` with the object keys inside it + // renamed. Every refusal the names alone can answer is raised here, + // before the base exists: an object whose copy name is claimed, and a + // copy name the item grammar or the storage identifier limit refuses. + // The bodies are rewritten by the caller, after each is converted to + // today's protocol — the order the duplicate rewrites in. + let rename: NamespaceRename | undefined; + if (namespace !== sourceNamespace) { + const planned = planNamespaceRename({ + sourcePackageId, + sourceNamespace, + targetNamespace: namespace, + items: items.map((item) => ({ type: item.type, name: item.name, body: item.body })), + retryVerb: 'install', + }); + const refused: UnmanagedImportRenameRefusal[] = []; + for (const item of items) { + const reason = (item.type === 'object' ? planned.objectRefusal(item.name) : undefined) + ?? planned.copyNameRefusal(item.type, item.name); + if (reason !== undefined) refused.push({ type: item.type, name: item.name, reason }); + for (const view of item.expands) { + const expanded = planned.copyNameRefusal('view', view); + if (expanded !== undefined) refused.push({ type: 'view', name: view, reason: expanded }); + } + } + if (refused.length > 0) throw unmanagedInstallRenameRefusal(sourcePackageId, namespace, refused); + items = items.map((item) => ({ + ...item, + name: planned.copyName(item.type, item.name), + expands: item.expands.map((view) => planned.copyName('view', view)), + sourceName: item.name, + })); + rename = planned; + } const sourceVersion = stringKey(manifest, 'version'); const baseManifest: Record = { ...identity, @@ -480,6 +575,8 @@ export function planUnmanagedImport( sourcePackageId, ...(sourceVersion !== undefined ? { sourceVersion } : {}), namespace, + sourceNamespace, + ...(rename !== undefined ? { rename } : {}), targetPackageId, baseManifest, items, @@ -487,6 +584,37 @@ export function planUnmanagedImport( }; } +/** [#30794] One item the rename into a `targetNamespace` cannot copy, and why. */ +export interface UnmanagedImportRenameRefusal { + type: string; + name: string; + reason: string; +} + +/** + * [#30794] The rename refusal: the package cannot be copied into + * `targetNamespace` as it declares its names, naming every item. Raised before + * the base exists — by the planner for what the names alone answer, and by the + * import for a reference collision a converted body meets. `INVALID_REQUEST` / + * 400, the code the body's own unwritable names answer + * ({@link enumerateItems}). + */ +export function unmanagedInstallRenameRefusal( + sourcePackageId: string, + targetNamespace: string, + refused: UnmanagedImportRenameRefusal[], +): Error { + const shown = refused.slice(0, 3).map((r) => r.reason).join(' '); + const more = refused.length > 3 ? ` (+${refused.length - 3} more)` : ''; + return refusal( + 'INVALID_REQUEST', + 400, + `Package '${sourcePackageId}' cannot be installed unmanaged into namespace '${targetNamespace}': ` + + `${refused.length} item(s) cannot be renamed. ${shown}${more} Nothing was installed.`, + { targetNamespace, refused }, + ); +} + /** The metadata type a `translations` bundle's locales are copied as. */ const TRANSLATION_TYPE = 'translation'; diff --git a/packages/metadata-protocol/src/protocol-package-commits.ts b/packages/metadata-protocol/src/protocol-package-commits.ts index 011ea1702c6..3a94512c728 100644 --- a/packages/metadata-protocol/src/protocol-package-commits.ts +++ b/packages/metadata-protocol/src/protocol-package-commits.ts @@ -34,7 +34,12 @@ import type { import { packageScopedRowWhere } from './sys-metadata-repository.js'; import type { SysMetadataRepository } from './sys-metadata-repository.js'; import { ConflictError, assertProtocolCompat } from '@objectstack/metadata-core'; -import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; +import { + planUnmanagedImport, + unmanagedInstallPostureRefusal, + unmanagedInstallRenameRefusal, + type UnmanagedImportRenameRefusal, +} from './package-unmanaged-install.js'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { DEFAULT_METADATA_TYPE_REGISTRY, @@ -42,7 +47,7 @@ import { deriveNamespaceFromPackageId, manifestIdRefusal, } from '@objectstack/spec/kernel'; -import { REFERENCE_SITES } from './reference-sites.js'; +import { planNamespaceRename } from './package-namespace-rename.js'; import { extensionFieldSuffixRefusalError, judgeExtensionFieldSuffix } from './protocol-extension-field-suffix.js'; import { customizationPairManifestRefusal } from './protocol-customization-package.js'; import { @@ -269,307 +274,31 @@ export class ProtocolPackageCommitsFacet { // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows = (await host.findEnvironmentRows('sys_metadata', { where, context: { isSystem: true } })) as any[]; - // 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)}` - : name; - - // ⭐ EVERY OBJECT GETS A NAME IN THE TARGET NAMESPACE (ADR-0070 D4, read - // with ADR-0129 D2). An object name IS its table name and its registry - // key, so a copy that keeps the source's name is a second claim on the - // source's object: before PR #23510 it stored a target row the registry - // refused, over the source's table, and after it the copy failed with - // the ADR-0029 D9.9 ownership prescription, which names no way out for - // a duplicate. ADR-0129 D2 says what the name is: every object a - // package defines is `${namespace}_${shortName}`, and the prefix rule's - // own prescription for a name that lacks the prefix - // (`validateObjectNamespacePrefix`) is "rename it to - // `${namespace}_${name}`". The copy is a NEW object, so that rename - // costs nothing here — while renaming the SOURCE object would move its - // table. A prefixed name swaps the source prefix for the target's; - // a name WITHOUT the source prefix (a direct-publish save stored - // before that door read the prefix rule — #24547 — or a package that - // had no namespace) takes the target prefix in front of its whole name. - // - // ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by - // the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name - // `iojn2_ticket`) or by an object the source package holds — the object - // is reported in `failed[]` before anything is written for it, at its - // own name, with the rename that clears it. A second invented name - // (a numeric suffix, a third prefix) would be a name nobody wrote. - const objectNames = new Set( - rows.filter((row) => row?.type === 'object' && typeof row.name === 'string').map((row) => row.name as string), - ); - const renameMap = new Map(); - const prefixed: string[] = []; - const unprefixed: string[] = []; - for (const name of objectNames) { - if (sourceNs && name.startsWith(`${sourceNs}_`)) prefixed.push(name); - else unprefixed.push(name); - } - for (const name of prefixed) { - const nn = renameName(name); - if (nn !== name) renameMap.set(name, nn); - } - // Every name a prefixed object's copy takes (itself, when the target - // namespace IS the source's), and every name the source holds. - const claimedBy = new Map(prefixed.map((name) => [renameName(name), name])); - const refusedObjects = new Map(); - for (const name of unprefixed) { - const nn = `${targetNs}_${name}`; - const holder = claimedBy.get(nn) ?? (objectNames.has(nn) ? nn : undefined); - if (holder !== undefined) { - const lacks = sourceNs - ? `lacks the '${sourceNs}_' prefix of package '${request.sourcePackageId}' (ADR-0129 D2: ` - + 'every object a package defines carries its namespace as a name prefix)' - : `carries no namespace prefix (package '${request.sourcePackageId}' declares no namespace)`; - const taken = holder === nn - ? `'${nn}' is an object package '${request.sourcePackageId}' already holds` - : `'${nn}' is already the copy name of object '${holder}'`; - refusedObjects.set( - name, - `Object '${name}' is not copied: its name ${lacks}, so its copy is named '${nn}' in ` - + `namespace '${targetNs}', and ${taken}. Rename '${name}' on the source to ` - + `${sourceNs ? `a '${sourceNs}_' name` : 'a name'} no other object in the package uses, ` - + 'then duplicate again.', - ); - continue; - } - renameMap.set(name, nn); - claimedBy.set(nn, name); - } - - // Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts - // `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. - // - // ⭐ A PREFIXED NAME THAT IS ALSO A FIELD KEY IS NOT DISTINCTIVE. When a - // package object declares a field keyed like a prefixed package object - // (`fields.iojn_customer` beside the object `iojn_customer`), a free-text - // value spelled that way may name the field — a list view's `columns`, - // a form's `sections[].fields` — and the field keeps its key. Renaming - // it as the object copied a column or form field the copy does not - // have. Such a name is treated as a bare name is: rewritten only at an - // object-reference property (below), never in free text. - const fieldKeys = new Set(); - for (const row of rows) { - if (row?.type !== 'object') continue; - let body: any; - try { - body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; - } catch { - continue; // the copy loop reports an unparseable row at its own name - } - const fields = body?.fields; - if (fields && typeof fields === 'object' && !Array.isArray(fields)) { - for (const key of Object.keys(fields)) fieldKeys.add(key); - } - } - const olds = prefixed - .filter((name) => renameMap.has(name) && !fieldKeys.has(name)) - .sort((a, b) => b.length - a.length); - const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = olds.length - ? new RegExp(`(?.` (`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` (`.`) 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(); - 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" - // into "A support iojn2_ticket raised …", and the same match renames the - // value of a lookup FIELD called `ticket` (`fields.ticket.name`). So a - // value is rewritten only where it sits under a property that names an - // object — the derived site index (`REFERENCE_SITES`, target `object`: - // `reference`, `object`, `objectName`, `objects`, …), read by property - // wherever it occurs in any item, because flow node configs (`config. - // objectName`) are declared by node descriptors the index does not - // walk — and only when it equals a renamed name exactly, in the four - // shapes a name arrives in there: a string, an array element, a record - // KEY (`permission.objects` is keyed by object name) or a `.name`. The - // record key holds for prefixed names too: a copied permission set - // otherwise kept `objects.`, a grant on the SOURCE. - // - // ⛔ REFUSED, NEVER MERGED: at a reference site, a renamed name can - // meet a name the item already holds there — the source also names - // the object the copy is about to be called (`dupdst_customer`, an - // object outside the package or not yet created) beside its own - // (`dupsrc_customer`). Two record KEYS that land on one key keep only - // the last entry: a permission set's `objects` dropped a grant (and - // the copied object could gain edit and delete its source never gave - // it), a translation's `objects` dropped a label. Two array ELEMENTS - // that land on one name bind it twice: a hook's handler ran twice per - // event on the copied object. So every such meeting is recorded in - // `collisions` — the path, the name that moved, the name it met — and - // the call site refuses the item in `failed[]` before anything is - // written for it. `renameMap` is one-to-one, so a meeting is always a - // renamed name and a name this copy does not rename; an element the - // source itself repeats is copied as stored. - const objectReferenceProperties = new Set( - (REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property), - ); - type ReferenceCollision = { at: string; shape: 'key' | 'entry'; moved: string; met: string }; - const deepRewrite = (v: any, collisions: ReferenceCollision[], atReference = false, at = ''): any => { - 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)) { - const out = v.map((element, i) => deepRewrite(element, collisions, atReference, `${at}[${i}]`)); - if (atReference) { - const firstAt = new Map(); - out.forEach((element, i) => { - if (typeof element !== 'string') return; - const j = firstAt.get(element); - if (j === undefined) { - firstAt.set(element, i); - } else if (v[j] !== v[i]) { - collisions.push({ at, shape: 'entry', moved: v[i] !== element ? v[i] : v[j], met: element }); - } - }); - } - return out; - } - if (v && typeof v === 'object') { - const o: any = {}; - const from = new Map(); - for (const [k, val] of Object.entries(v)) { - const key = atReference ? (renameMap.get(k) ?? k) : k; - const other = from.get(key); - if (other !== undefined) { - collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key }); - } - from.set(key, k); - 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; - } - return v; - }; - const describeCollision = (c: ReferenceCollision): string => - `its \`${c.at}\` ${c.shape} '${c.moved}' follows the copied object to '${c.met}', ` - + `${c.shape === 'key' ? 'a key' : 'an entry'} it already holds there, which this copy does not rename. ` - + (c.shape === 'key' - ? 'A record keeps one entry per key, so the copy would drop one of the two. ' - : `The copy would name '${c.met}' twice. `) - + `Remove '${c.moved}' or '${c.met}' on the source, then duplicate again.`; - // ⭐ A COPIED PERMISSION SET'S FIELD RULES FOLLOW THE RENAMED OBJECTS. - // `PermissionSetSchema.fields` is a record keyed `.` - // (`packages/spec/src/security/permission.zod.ts`), and the security - // plugin reads a set's rules for an object by the key prefix - // `.` (`PermissionEvaluator.getFieldPermissions`). Left as - // stored, the copy's rules govern the SOURCE's object and the - // duplicate's object gets none: a field the source set hides is - // readable on the copy for whoever holds the copied set. - // - // `deepRewrite` cannot carry it: the derived site index reads - // `permission.fields` as a site naming `field` items (its property - // spells that type), never an object, and has no arm for a composite - // key — and widening the index would also change the "Used by" answer - // it feeds (`findReferencesToMeta`). So this step is keyed on the - // declared shape — the `fields` record of a copied `permission` item — - // never on a property name, which would also catch an object's own - // `fields` (keyed by field name). - // - // The split: object and field names are both `^[a-z_][a-z0-9_]*$`, so - // a key's object part is everything before its FIRST `.`. It is - // rewritten only when it equals a renamed object name exactly, and the - // field part is never read — `dupsrc_customer.dupsrc_customer` becomes - // `dupdst_customer.dupsrc_customer`. A key naming an object outside the - // package keeps its name. - // - // ⛔ REFUSED, NEVER MERGED: when a renamed key equals a key the set - // already holds (the source also governs an object, outside the - // package, named like the copy), keeping either rule would silently - // drop the other, so the set is reported in `failed[]` with both keys. - // `Object.fromEntries` defines own properties, so no key can land on - // the prototype. - const rewriteFieldRuleKeys = (fields: Record): { fields: Record } | { collision: string } => { - const out = new Map(); - const from = new Map(); - for (const [key, rule] of Object.entries(fields)) { - const dot = key.indexOf('.'); - const renamed = dot > 0 ? renameMap.get(key.slice(0, dot)) : undefined; - const next = renamed === undefined ? key : `${renamed}${key.slice(dot)}`; - const other = from.get(next); - if (other !== undefined) { - const moved = next === key ? other : key; - return { - collision: `its field rule '${moved}' follows object '${moved.slice(0, moved.indexOf('.'))}' ` - + `to '${next}', a key the set already holds for object ` - + `'${next.slice(0, next.indexOf('.'))}', which this copy does not rename. ` - + `Remove '${moved}' or '${next}' on the source, then duplicate again.`, - }; + // [#30794] ⭐ ONE RENAME ENGINE, TWO CALLERS. Every rule that names the + // copy — the object renames and the refusal of a claimed name, the + // whole-identifier free-text rewrite, a view item following its + // object, a name rewritten whole at an object-reference property, a + // record entry's `name` following its key, a permission set's field + // rules following the renamed objects, and the refusal of every + // collision those rewrites meet — lives in `package-namespace-rename.ts`, + // which the unmanaged import's `targetNamespace` calls too. This door + // hands it the source's rows, parsed; an unparseable row is left to the + // copy loop, which reports it at its own name. + const rename = planNamespaceRename({ + sourcePackageId: request.sourcePackageId, + sourceNamespace: sourceNs, + targetNamespace: targetNs, + items: rows.map((row) => { + let body: unknown; + try { + body = typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : row?.metadata; + } catch { + body = undefined; } - out.set(next, rule); - from.set(next, key); - } - return { fields: Object.fromEntries(out) }; - }; + return { type: row?.type, name: row?.name, body }; + }), + retryVerb: 'duplicate', + }); // [ADR-0146 D10, #28564] ⭐ THE `__c` RESERVATION, JUDGED AGAINST THE // TARGET, before the copy's first mutation (the package record below): @@ -592,7 +321,7 @@ export class ProtocolPackageCommitsFacet { continue; } if (body && typeof body === 'object' && !Array.isArray(body)) { - copiedObjects[row.name] = { ...(body as Record), name: renameMap.get(row.name) ?? row.name }; + copiedObjects[row.name] = { ...(body as Record), name: rename.copyName(row.type, row.name) }; } } const suffixRefusals = judgeExtensionFieldSuffix({ packageId: request.targetPackageId, objects: copiedObjects }); @@ -662,16 +391,12 @@ export class ProtocolPackageCommitsFacet { const canonicalizeFlow = host.resolveFlowCanonicalizer(); for (const row of rows) { - const refusal = row?.type === 'object' ? refusedObjects.get(row.name) : undefined; + const refusal = row?.type === 'object' ? rename.objectRefusal(row.name) : undefined; if (refusal !== undefined) { failed.push({ type: row.type, name: row.name, error: refusal }); continue; } - const newName = row?.type === 'object' - ? (renameMap.get(row.name) ?? row.name) - : isQualifiedViewRow(row) - ? (viewItemRenames.get(row.name) ?? row.name) - : renameName(row.name); + const newName = rename.copyName(row.type, row.name); const rawType = String(row.type); const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType; let body: unknown; @@ -734,23 +459,17 @@ export class ProtocolPackageCommitsFacet { continue; } } - const collisions: ReferenceCollision[] = []; - const rewritten = deepRewrite(item, collisions); - if (collisions.length > 0) { - const noun = singular === 'permission' - ? 'Permission set' - : `${singular.charAt(0).toUpperCase()}${singular.slice(1)}`; - const more = collisions.length - 1; - failed.push({ - type: row.type, - name: row.name, - error: `${noun} '${row.name}' is not copied: ${describeCollision(collisions[0]!)}` - + (more > 0 ? ` It has ${more} more such collision${more === 1 ? '' : 's'}.` : ''), - }); + // The engine names the copy: every renamed name rewritten, `name` + // set to `newName`, a permission set's field rules following the + // renamed objects — or the collision that refuses the item, before + // anything is written for it. + const outcome = rename.rewrite(row.type, row.name, item); + if ('refusal' in outcome) { + failed.push({ type: row.type, name: row.name, error: outcome.refusal }); continue; } + const rewritten: any = outcome.item; if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) { - rewritten.name = newName; // [ADR-0086 D3] A permission set that declares its SOURCE // package as its owner is copied declaring the target, the // package the copy is saved into: the save door refuses a @@ -762,19 +481,6 @@ export class ProtocolPackageCommitsFacet { if (singular === 'permission' && rewritten.packageId === request.sourcePackageId) { rewritten.packageId = request.targetPackageId; } - if (singular === 'permission' && rewritten.fields - && typeof rewritten.fields === 'object' && !Array.isArray(rewritten.fields)) { - const result = rewriteFieldRuleKeys(rewritten.fields); - if ('collision' in result) { - failed.push({ - type: row.type, - name: row.name, - error: `Permission set '${row.name}' is not copied: ${result.collision}`, - }); - continue; - } - rewritten.fields = result.fields; - } } try { await host.saveMetaItem({ @@ -871,6 +577,9 @@ export class ProtocolPackageCommitsFacet { const plan = planUnmanagedImport(declared.data.manifest, { ...(declared.data.targetPackageId !== undefined ? { targetPackageId: declared.data.targetPackageId } : {}), ...(declared.data.targetName !== undefined ? { targetName: declared.data.targetName } : {}), + // [#30794] Rename-on-install: judged by the plan, applied by the + // duplicate's own engine (`package-namespace-rename.ts`). + ...(declared.data.targetNamespace !== undefined ? { targetNamespace: declared.data.targetNamespace } : {}), // The save door's own predicate — registry-only, the hatch unread. isRuntimeCreatable: (type) => host.isRuntimeCreateAllowed(type), // The save door's own schema, asked which types declare the @@ -879,7 +588,7 @@ export class ProtocolPackageCommitsFacet { loadOrderOf: (type) => DEFAULT_METADATA_TYPE_REGISTRY.find((entry) => entry.type === type)?.loadOrder ?? Number.MAX_SAFE_INTEGER, }); - const { sourcePackageId, targetPackageId, namespace } = plan; + const { sourcePackageId, targetPackageId, namespace, rename } = plan; // 5. The handshake `installPackage` asks too — asked here so a refusal // precedes the base. @@ -896,34 +605,58 @@ export class ProtocolPackageCommitsFacet { } const owners: unknown = registry?.getNamespaceOwners?.(namespace); if (Array.isArray(owners) && owners.length > 0) { + const named = owners.map((o) => `'${String(o)}'`).join(', '); throw Object.assign(new Error( - `The namespace '${namespace}' of package '${sourcePackageId}' is already owned by ` - + `${owners.map((o) => `'${String(o)}'`).join(', ')}. The unmanaged copy keeps the source namespace, ` - + 'so it cannot sit beside a package that owns it — a managed install of this same package ' - + 'included. Uninstall that package first, or keep the managed install.', + rename + // [#30794] The namespace the request named is someone else's. + ? `The namespace '${namespace}' named by \`targetNamespace\` is already owned by ${named}, so the ` + + `unmanaged copy of '${sourcePackageId}' cannot take it. Name another \`targetNamespace\`, or ` + + 'uninstall that package first.' + : `The namespace '${namespace}' of package '${sourcePackageId}' is already owned by ` + + `${named}. The unmanaged copy keeps the source namespace, ` + + 'so it cannot sit beside a package that owns it — a managed install of this same package ' + + 'included. Uninstall that package first, keep the managed install, or install the copy ' + + 'under a namespace of its own with `targetNamespace`.', ), { code: 'RESOURCE_CONFLICT', status: 409, details: { namespace, owners } }); } // 7. Today's protocol, item by item — the conversion a managed install // applies to an artifact, so an older package imports as it installs. + // Converted under the name the package declares it, as the duplicate + // converts a source row; then, when the copy is renamed, rewritten by + // the duplicate's own engine — after the conversion, as the duplicate + // rewrites — every refusal it meets raised before the base exists. const canonicalizeFlow = plan.items.some((i) => i.type === 'flow') ? host.resolveFlowCanonicalizer() : undefined; const prepared: Array<{ type: string; name: string; item: unknown }> = []; const unconvertible: string[] = []; + const unrenamable: UnmanagedImportRenameRefusal[] = []; for (const planned of plan.items) { + const declaredName = planned.sourceName ?? planned.name; + let converted: unknown; try { if (planned.type !== 'flow') { - prepared.push({ type: planned.type, name: planned.name, item: host.convertStoredItem(planned.type, planned.body) }); + converted = host.convertStoredItem(planned.type, planned.body); } else if (!canonicalizeFlow) { // No engine in this process: the body as declared, exactly as // `duplicatePackage` copies it; the save door still judges it. - prepared.push({ type: planned.type, name: planned.name, item: planned.body }); + converted = planned.body; } else { - const result = canonicalizeFlow(planned.name, planned.body); - prepared.push({ type: planned.type, name: planned.name, item: result.storable }); + converted = canonicalizeFlow(declaredName, planned.body).storable; } } catch (e: any) { - unconvertible.push(`${planned.type} '${planned.name}': ${e?.message ?? String(e)}`); + unconvertible.push(`${planned.type} '${declaredName}': ${e?.message ?? String(e)}`); + continue; + } + if (rename === undefined) { + prepared.push({ type: planned.type, name: planned.name, item: converted }); + continue; + } + const outcome = rename.rewrite(planned.type, declaredName, converted); + if ('refusal' in outcome) { + unrenamable.push({ type: planned.type, name: declaredName, reason: outcome.refusal }); + continue; } + prepared.push({ type: planned.type, name: planned.name, item: outcome.item }); } if (unconvertible.length > 0) { throw Object.assign(new Error( @@ -933,6 +666,7 @@ export class ProtocolPackageCommitsFacet { + 'this protocol, then install again.', ), { code: 'INVALID_METADATA', status: 422 }); } + if (unrenamable.length > 0) throw unmanagedInstallRenameRefusal(sourcePackageId, namespace, unrenamable); // 8. Every name the copy would take, against the environment and the // managed packages — the save door would refuse or overlay them. const collisions = await host.unmanagedImportCollisions(plan.items); @@ -940,8 +674,12 @@ export class ProtocolPackageCommitsFacet { throw Object.assign(new Error( `Package '${sourcePackageId}' cannot be installed unmanaged: ${collisions.length} of its names are already ` + `taken — ${collisions.slice(0, 3).map((c) => `${c.type} '${c.name}' (${c.heldBy})`).join('; ')}` - + `${collisions.length > 3 ? ` (+${collisions.length - 3} more)` : ''}. The copy keeps every name ` - + 'the package declares, so remove or rename the items that hold them, then install again.', + + `${collisions.length > 3 ? ` (+${collisions.length - 3} more)` : ''}. ` + + (rename + ? `The copy takes every name the package declares, renamed into namespace '${namespace}', so remove ` + + 'or rename the items that hold them, or name another `targetNamespace`, then install again.' + : 'The copy keeps every name the package declares, so remove or rename the items that hold them, ' + + 'or install it under a namespace of its own with `targetNamespace`, then install again.'), ), { code: 'RESOURCE_CONFLICT', status: 409, details: { collisions } }); } @@ -978,6 +716,8 @@ export class ProtocolPackageCommitsFacet { ...(plan.sourceVersion !== undefined ? { sourceVersion: plan.sourceVersion } : {}), targetPackageId, namespace, + // [#30794] The provenance of every renamed name. + ...(rename !== undefined ? { sourceNamespace: plan.sourceNamespace } : {}), copiedCount: copied.length, copied, // Planned before the base existed; every item was written, so every diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c4036ad3c25..950d189c165 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -12880,7 +12880,24 @@ export class ObjectStackProtocolImplementation implements * * Then the base is installed ({@link installPackage}, scope-less, the * source namespace kept, an id that differs from the source) and every item - * is written in registry `loadOrder`. A write that fails deletes the base + * is written in registry `loadOrder`. + * + * ## Rename-on-install (`targetNamespace`, unmanaged only) + * + * A request naming a `targetNamespace` copies the package under that + * namespace instead of the source's: it is the base's namespace, every + * copied object name takes it in place of the source prefix, and every + * reference to a renamed name is rewritten — the rename + * {@link duplicatePackage} applies, by the same engine + * (`package-namespace-rename.ts`), after the view containers are expanded. + * Every refusal that rename can meet is raised before the base exists: a + * namespace its declaration refuses or the platform reserves (400), an + * object whose copy name is claimed, a copy name the item grammar or the + * storage identifier limit refuses, and a reference collision (400 + * `INVALID_REQUEST`, naming each item). The collision check (8.) judges + * the renamed names, and the answer's `sourceNamespace` records the + * namespace the names came from. A managed install is never renamed: it + * keeps its upgrade channel. A write that fails deletes the base * ({@link deletePackage}, which removes its rows and drops its tables), and * the failure is thrown with the item's own `code` and `status`, naming the * item and what the rollback did. There is no half-copied app. From a9df9d64151ad4c9518bf57cc29806721a1eb899 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:33:06 +0000 Subject: [PATCH 3/8] test(metadata-protocol,cloud-connection): the rename engine alone, and the install-local door's targetNamespace The engine's own pins: its storage limits read off their declarations, a copy name the item grammar refuses is refused naming the item, an item the copy does not rename is never judged by name, and the remedy names the door. The door forwards targetNamespace to an unmanaged install, records the source namespace in installed_from, and refuses it on a managed install before any fetch. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- ...tplace-install-local-install-modes.test.ts | 61 +++++++++++++++ .../src/package-namespace-rename.test.ts | 78 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/metadata-protocol/src/package-namespace-rename.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts index ac81bbeeba5..28be1bfeddb 100644 --- a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts @@ -506,3 +506,64 @@ describe('[#23914] a truncated install body is refused, and nothing is written', expect(empty.payload).toEqual(none.payload); }); }); + +describe('install-local — `targetNamespace` renames an unmanaged copy, and only an unmanaged one (#30794)', () => { + /** The import as it answers a renamed copy: the request's namespace, and the source's beside it. */ + const renamingImporter = async (request: any) => ({ + sourcePackageId: request.manifest.id, + sourceVersion: request.manifest.version, + targetPackageId: `${request.manifest.id}-copy`, + namespace: request.targetNamespace ?? request.manifest.namespace, + ...(request.targetNamespace !== undefined ? { sourceNamespace: request.manifest.namespace } : {}), + copiedCount: 1, + copied: [{ type: 'object', name: `${request.targetNamespace ?? request.manifest.namespace}_lead` }], + strippedLocks: [], + }); + + it('hands `targetNamespace` to the import, and records the source namespace beside `installedFrom`', async () => { + const h = await mount(dir, { importer: renamingImporter }); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged', targetNamespace: 'acme' }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + + expect(h.importUnmanagedPackage).toHaveBeenCalledTimes(1); + expect(h.importUnmanagedPackage!.mock.calls[0]![0]).toMatchObject({ targetNamespace: 'acme', actor: INSTALLER }); + // The mapping the rename applied: `installedFrom.namespace` → `namespace`. + expect(res.payload.data).toMatchObject({ + installMode: 'unmanaged', + namespace: 'acme', + installedFrom: { packageId: 'com.example.crm', version: '4.0.0', namespace: 'crm' }, + copied: [{ type: 'object', name: 'acme_lead' }], + }); + const info = h.logger.info.mock.calls.map((call: any[]) => String(call[0])).join('\n'); + expect(info).toContain("(namespace 'acme', renamed from 'crm')"); + }); + + it('without it the import is asked as before, and `installedFrom` carries no namespace — the key set unchanged', async () => { + const h = await mount(dir, { importer: renamingImporter }); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(200); + expect(h.importUnmanagedPackage!.mock.calls[0]![0]).not.toHaveProperty('targetNamespace'); + expect(res.payload.data.installedFrom).toEqual({ packageId: 'com.example.crm', version: '4.0.0' }); + }); + + for (const installMode of [undefined, 'managed'] as const) { + it(`beside a ${installMode ?? 'default (managed)'} install: 400 INVALID_REQUEST — a managed install is never renamed; nothing fetched or written`, async () => { + const h = await mount(dir, { importer: renamingImporter }); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const res = await h.install({ + manifest: crm({ installModes: BOTH }), + ...(installMode ? { installMode } : {}), + targetNamespace: 'acme', + }); + expect(res.status).toBe(400); + expect(res.payload.error.code).toBe('INVALID_REQUEST'); + expect(res.payload.error.message).toContain('a managed install is never renamed'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(h.register).not.toHaveBeenCalled(); + expect(h.syncSchemas).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + } +}); diff --git a/packages/metadata-protocol/src/package-namespace-rename.test.ts b/packages/metadata-protocol/src/package-namespace-rename.test.ts new file mode 100644 index 00000000000..b6427f83c21 --- /dev/null +++ b/packages/metadata-protocol/src/package-namespace-rename.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30794] The rename engine alone — `planNamespaceRename`, the pure function + * both copy doors call. The doors' own pins live beside their harnesses: the + * duplicate's in the objectql `protocol-duplicate-package-*` battery, the + * unmanaged import's (and the parity of the two) in + * `package-unmanaged-install.test.ts`. What is pinned here is what no door can + * reach: the engine does not trust its caller's judgement of the names. + */ +import { describe, expect, it } from 'vitest'; +import { + METADATA_ITEM_NAME_STORAGE_LIMIT, + OBJECT_TABLE_IDENTIFIER_LIMIT, + planNamespaceRename, +} from './package-namespace-rename.js'; + +const items = [ + { type: 'object', name: 'crm_lead', body: { name: 'crm_lead', fields: { title: { name: 'title', type: 'text' } } } }, + { type: 'view', name: 'crm_lead.board', body: { name: 'crm_lead.board', object: 'crm_lead' } }, + { type: 'translation', name: 'crm_en', body: { name: 'crm_en', locale: 'en', objects: { crm_lead: { label: 'Lead' } } } }, +]; + +describe('planNamespaceRename — the one rename engine (#30794)', () => { + it('reads its storage limits off their declarations: the table identifier, and the `sys_metadata.name` column', () => { + expect(OBJECT_TABLE_IDENTIFIER_LIMIT).toBe(63); + expect(METADATA_ITEM_NAME_STORAGE_LIMIT).toBe(255); + }); + + it('a copy name the item grammar refuses is refused, naming the item — the engine does not trust its caller', () => { + // No door hands the engine a namespace its declaration refuses; the engine judges the copy name anyway. + const rename = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'Acme', items, retryVerb: 'install', + }); + expect(rename.copyName('object', 'crm_lead')).toBe('Acme_lead'); + expect(rename.copyNameRefusal('object', 'crm_lead')) + .toContain("Object 'crm_lead' is not copied: its copy name 'Acme_lead' in namespace 'Acme' is not a legal metadata item name"); + expect(rename.copyNameRefusal('view', 'crm_lead.board')).toContain("View 'crm_lead.board' is not copied"); + }); + + it('an item the copy does not rename is never refused by name, whatever its length', () => { + const long = `ext_${'x'.repeat(70)}`; + const rename = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', + items: [...items, { type: 'object', name: long, body: {} }], retryVerb: 'install', + }); + // `ext_…` lacks the source prefix, so it takes the target prefix in front — a rename, judged. + expect(rename.copyNameRefusal('object', long)).toContain('storage identifier limit'); + // A view of an object outside the package keeps its name, so nothing is judged. + expect(rename.copyName('view', `other_${'y'.repeat(300)}.board`)).toBe(`other_${'y'.repeat(300)}.board`); + expect(rename.copyNameRefusal('view', `other_${'y'.repeat(300)}.board`)).toBeUndefined(); + }); + + it('the remedy names the door: "then duplicate again" for the duplicate, "then install again" for the import', () => { + const withBare = [...items, { type: 'object', name: 'lead', body: {} }]; + const duplicate = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', items: withBare, retryVerb: 'duplicate', + }); + const install = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', items: withBare, retryVerb: 'install', + }); + expect(duplicate.objectRefusal('lead')).toMatch(/then duplicate again\.$/); + expect(install.objectRefusal('lead')).toMatch(/then install again\.$/); + expect(install.objectRefusal('crm_lead')).toBeUndefined(); + }); + + it('rewrites a body: names, the reference site, a record key, and `name` set to the copy name', () => { + const rename = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', items, retryVerb: 'install', + }); + expect(rename.rewrite('view', 'crm_lead.board', items[1]!.body)).toEqual({ + item: { name: 'acme_lead.board', object: 'acme_lead' }, + }); + expect(rename.rewrite('translation', 'crm_en', items[2]!.body)).toEqual({ + item: { name: 'acme_en', locale: 'en', objects: { acme_lead: { label: 'Lead' } } }, + }); + }); +}); From f604be394026f1de721ca553a20fb45339411952 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:39:40 +0000 Subject: [PATCH 4/8] fix(metadata-protocol): a view named by its object follows the object's copy name; the duplicate battery replayed through the import A view container is named by the object it binds, which is how the unmanaged import names every container and how a stored base holds one. The prefix swap kept a bare object's container under its old name while the body's object followed the copy, so the copy's container sat under the name the source's container holds. The engine now gives such a view its object's copy name, in both doors. The objectql battery replay declares each duplicate fixture as a package body and imports it into the same namespace on a real kernel: the copy stores the same rows the duplicate stores, and every item the duplicate fails is refused by the import before anything is written. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- .../src/package-namespace-rename.test.ts | 17 + .../src/package-namespace-rename.ts | 22 +- ...ol-unmanaged-import-rename-battery.test.ts | 633 ++++++++++++++++++ 3 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 packages/objectql/src/protocol-unmanaged-import-rename-battery.test.ts diff --git a/packages/metadata-protocol/src/package-namespace-rename.test.ts b/packages/metadata-protocol/src/package-namespace-rename.test.ts index b6427f83c21..92bc4cf3573 100644 --- a/packages/metadata-protocol/src/package-namespace-rename.test.ts +++ b/packages/metadata-protocol/src/package-namespace-rename.test.ts @@ -64,6 +64,23 @@ describe('planNamespaceRename — the one rename engine (#30794)', () => { expect(install.objectRefusal('crm_lead')).toBeUndefined(); }); + it('a view named by its object follows the object whole — a bare object\'s container included', () => { + const rename = planNamespaceRename({ + sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', + items: [...items, { type: 'object', name: 'ticket', body: {} }, { type: 'view', name: 'ticket', body: { object: 'ticket' } }], + retryVerb: 'install', + }); + // The prefix swap kept `ticket`, while the container's `object` follows the copy to `acme_ticket`. + expect(rename.copyName('view', 'ticket')).toBe('acme_ticket'); + expect(rename.copyName('views', 'ticket')).toBe('acme_ticket'); + expect(rename.rewrite('view', 'ticket', { object: 'ticket' })).toEqual({ item: { name: 'acme_ticket', object: 'acme_ticket' } }); + // A prefixed object's container: the name the swap gave already. + expect(rename.copyName('view', 'crm_lead')).toBe('acme_lead'); + // NEGATIVE CONTROL: a bare name that is no object of the package keeps it, and so does a non-view row. + expect(rename.copyName('view', 'board')).toBe('board'); + expect(rename.copyName('action', 'ticket')).toBe('ticket'); + }); + it('rewrites a body: names, the reference site, a record key, and `name` set to the copy name', () => { const rename = planNamespaceRename({ sourcePackageId: 'com.example.crm', sourceNamespace: 'crm', targetNamespace: 'acme', items, retryVerb: 'install', diff --git a/packages/metadata-protocol/src/package-namespace-rename.ts b/packages/metadata-protocol/src/package-namespace-rename.ts index 29db8f693b2..06314a02044 100644 --- a/packages/metadata-protocol/src/package-namespace-rename.ts +++ b/packages/metadata-protocol/src/package-namespace-rename.ts @@ -119,10 +119,14 @@ export const OBJECT_TABLE_IDENTIFIER_LIMIT = 63; export const METADATA_ITEM_NAME_STORAGE_LIMIT: number = (SysMetadataObject.fields as Record).name?.maxLength ?? 255; +/** A `view` row, in either spelling of the type. */ +function isViewType(type: string): boolean { + return (PLURAL_TO_SINGULAR[String(type)] ?? String(type)) === 'view'; +} + /** A `view` row whose name is qualified `.` (`ViewItemNameSchema`). */ function isQualifiedViewName(type: string, name: unknown): name is string { - return (PLURAL_TO_SINGULAR[String(type)] ?? String(type)) === 'view' - && typeof name === 'string' && name.includes('.'); + return isViewType(type) && typeof name === 'string' && name.includes('.'); } /** A body's `fields` record keys, when it has one. */ @@ -433,12 +437,24 @@ export function planNamespaceRename(input: NamespaceRenameInput): NamespaceRenam return { fields: Object.fromEntries(out) }; }; + // [#30794] ⭐ A VIEW NAMED BY ITS OBJECT FOLLOWS THE OBJECT, whole. A view + // container is named by the object it binds — the unmanaged import names + // every container so (`deriveViewContainerObject`), and a stored base + // holds the container under that name — so a view whose name IS a copied + // object's name takes that object's copy name. For a prefixed object it + // is the name the prefix swap gave already; for a bare one (`ticket`, + // copied as `dupdst_ticket`) the swap kept `ticket` while the body's + // `object` followed the copy, so the copy's container sat under a name + // the source's container holds — the shadowing the qualified rule above + // closes for `ticket.board`. const copyName = (type: string, name: string): string => type === 'object' ? (renameMap.get(name) ?? name) : isQualifiedViewName(type, name) ? qualifiedViewCopyName(name) - : renameName(name); + : isViewType(type) && renameMap.has(name) + ? renameMap.get(name)! + : renameName(name); const nounOf = (type: string): string => { const singular = PLURAL_TO_SINGULAR[String(type)] ?? String(type); diff --git a/packages/objectql/src/protocol-unmanaged-import-rename-battery.test.ts b/packages/objectql/src/protocol-unmanaged-import-rename-battery.test.ts new file mode 100644 index 00000000000..4daeed1c077 --- /dev/null +++ b/packages/objectql/src/protocol-unmanaged-import-rename-battery.test.ts @@ -0,0 +1,633 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30794] The ADR-0070 D4 duplicate battery, replayed through the unmanaged + * import with a `targetNamespace`. + * + * ## What is pinned + * + * The unmanaged import renames a copy into `targetNamespace` through the + * duplicate's own rename engine (`package-namespace-rename.ts` in + * `@objectstack/metadata-protocol`) — one engine, two callers. So every rule + * the `protocol-duplicate-package-*` battery pins for `duplicatePackage` holds + * for the import too: each fixture of that battery is declared as a package + * body, imported into `com.qa.dupdst` under namespace `dupdst`, and the stored + * rows are compared with the rows the duplicate of the same package stores. + * + * Where the duplicate reports an item in `failed[]` and copies the rest, the + * import refuses the whole install before anything is written (`INVALID_REQUEST` + * / 400, every refused item named, with the remedy "then install again"): an + * unmanaged install is one copy of one package, never part of one. + * + * ## Why a real kernel + * + * `ObjectQLPlugin` binds the real protocol to the real engine and registry, so + * the save door, the registry and the data plane judge both doors' rows, and + * every pin reads the stored rows out of the driver, never a payload. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** In-memory driver, copied from `protocol-duplicate-package-objects-key-collision.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** Source package; its namespace `dupsrc` is derived from the id. */ +const P = 'com.qa.dupsrc'; +/** Target package. */ +const Q = 'com.qa.dupdst'; +/** The namespace both doors copy into: the one `duplicatePackage` derives from `Q`. */ +const TARGET_NS = 'dupdst'; +/** A third package, whose objects the source's items also name. */ +const R = 'com.qa.dupext'; + +interface Fixture { + type: string; + item: { name: string } & Record; + /** A bare object name under the namespaced source: a row stored before the save door read ADR-0129 D2 (#24547). */ + storedBeforeRule?: boolean; +} + +/** The manifest collection each fixture type is declared under. */ +const COLLECTION: Record = { + object: 'objects', view: 'views', action: 'actions', permission: 'permissions', flow: 'flows', hook: 'hooks', +}; + +interface Harness { + kernel: ObjectKernel; + stores: Stores; + engine: ObjectQL; + protocol: any; +} + +const booted: Harness[] = []; + +async function boot(): Promise { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const stores: Stores = new Map(); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + const harness = { kernel, stores, engine, protocol: kernel.getService('protocol') as any }; + booted.push(harness); + return harness; +} + +afterEach(async () => { + for (const { kernel } of booted.splice(0)) { + if (kernel.getState() === 'running') await kernel.shutdown(); + } +}); + +async function save(h: Harness, packageId: string, type: string, item: { name: string }): Promise { + const res = await h.protocol.saveMetaItem({ type, name: item.name, packageId, item }); + expect(res.success, JSON.stringify(res)).toBe(true); +} + +/** Saves a row while the package's manifest declares no namespace, then puts the derived one back (#24547). */ +async function saveStoredBeforeRule(h: Harness, packageId: string, type: string, item: { name: string }): Promise { + const manifest = h.engine.registry.getPackage(packageId)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(h, packageId, type, item); + } finally { + manifest.namespace = namespace; + } +} + +/** Installs the third package and its objects — one prefixed, one carrying the source prefix (stored before the rule). */ +async function seedOutside(h: Harness, outside: Fixture[]): Promise { + if (outside.length === 0) return; + await h.protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); + for (const f of outside) { + if (f.storedBeforeRule) await saveStoredBeforeRule(h, R, f.type, f.item); + else await save(h, R, f.type, f.item); + } +} + +/** The source as a stored base — the rows `duplicatePackage` reads — then duplicated into `Q`. */ +async function viaDuplicate(fixtures: Fixture[], outside: Fixture[] = []): Promise<{ h: Harness; res: any }> { + const h = await boot(); + await seedOutside(h, outside); + await h.protocol.installPackage({ + manifest: { id: P, name: 'Source', type: 'app', ...(outside.length > 0 ? { dependencies: { [R]: '*' } } : {}) }, + }); + expect(h.engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); + for (const f of fixtures) { + if (f.storedBeforeRule) await saveStoredBeforeRule(h, P, f.type, f.item); + else await save(h, P, f.type, f.item); + } + const res = await h.protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + return { h, res }; +} + +/** The same source as a package body. */ +function packageBody(fixtures: Fixture[], outside: Fixture[] = []): Record { + const body: Record = { + id: P, name: 'Source', type: 'app', ...(outside.length > 0 ? { dependencies: { [R]: '*' } } : {}), + }; + for (const { type, item } of fixtures) (body[COLLECTION[type]!] ??= []).push(item); + return body; +} + +/** The same source imported unmanaged straight into `Q` under `TARGET_NS`. */ +async function viaImport(fixtures: Fixture[], outside: Fixture[] = []): Promise<{ h: Harness; res: any; error: any }> { + const h = await boot(); + await seedOutside(h, outside); + try { + const res = await h.protocol.importUnmanagedPackage({ + manifest: packageBody(fixtures, outside), targetPackageId: Q, targetNamespace: TARGET_NS, + }); + return { h, res, error: undefined }; + } catch (error) { + return { h, res: undefined, error }; + } +} + +const rowsOf = (h: Harness) => Array.from(h.stores.get('sys_metadata')?.values() ?? []); +const bodyOf = (row: Record) => (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata); +const storedBody = (h: Harness, type: string, name: string, packageId: string): any => { + const row = rowsOf(h).find((r) => r.type === type && r.name === name && r.package_id === packageId); + return row ? bodyOf(row) : undefined; +}; +/** Every row of one package: `type/name` → its stored body. */ +const itemsOf = (h: Harness, packageId: string): Record => Object.fromEntries( + rowsOf(h) + .filter((r) => r.package_id === packageId) + .map((r) => [`${r.type}/${r.name}`, bodyOf(r)] as const) + .sort(([a], [b]) => a.localeCompare(b)), +); + +// ── `protocol-duplicate-package-names-in-step.test.ts` ───────────────────── + +const PROSE = 'Linked from dupsrc_customer; see x_dupsrc_customer.'; +const NOTE_PROSE = 'Attached to dupsrc_note; see x_dupsrc_note.'; +const RULE = { readable: true, editable: false }; +const grid = (object: string, columns: string[]) => ({ type: 'grid', data: { provider: 'object', object }, columns }); +const form = (object: string, fields: string[]) => ({ + type: 'simple', data: { provider: 'object', object }, sections: [{ label: 'Main', fields }], +}); +const viewItem = (object: string, key: string, viewKind: 'list' | 'form', config: unknown) => ({ + name: `${object}.${key}`, object, viewKind, label: key, config, +}); +const formAction = (name: string, objectName: string, target: string) => ({ + name, label: name, type: 'form', objectName, target, +}); + +const namesInStepOutside: Fixture[] = [ + { type: 'object', item: { name: 'dupext_account', label: 'Account', sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } }, + { type: 'object', item: { name: 'dupsrc_ext', label: 'External', sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, storedBeforeRule: true }, +]; +const namesInStep: Fixture[] = [ + { + type: 'object', item: { + name: 'dupsrc_customer', label: 'Customer', description: PROSE, sharingModel: 'private', + fields: { + full_name: { name: 'full_name', type: 'text', label: 'Full name' }, + dupsrc_customer: { name: 'dupsrc_customer', type: 'text', label: 'Customer code' }, + x_dupsrc_customer: { name: 'x_dupsrc_customer', type: 'text', label: 'Extra' }, + }, + }, + }, + { + type: 'object', storedBeforeRule: true, item: { + name: 'ticket', label: 'Ticket', sharingModel: 'private', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + customer: { name: 'customer', type: 'lookup', reference: 'dupsrc_customer', label: 'Customer' }, + }, + }, + }, + { + type: 'object', item: { + name: 'dupsrc_note', label: 'Note', description: NOTE_PROSE, sharingModel: 'private', + fields: { + x_dupsrc_note: { name: 'x_dupsrc_note', type: 'text', label: 'Extra' }, + customer: { name: 'customer', type: 'lookup', reference: 'dupsrc_customer', label: 'Customer' }, + }, + }, + }, + { type: 'view', item: viewItem('ticket', 'board', 'list', grid('ticket', ['title'])) }, + { type: 'view', item: viewItem('dupsrc_customer', 'board', 'list', grid('dupsrc_customer', ['full_name', 'dupsrc_customer', 'x_dupsrc_customer'])) }, + { type: 'view', item: viewItem('dupext_account', 'board', 'list', grid('dupext_account', ['name'])) }, + { type: 'view', item: viewItem('dupsrc_ext', 'board', 'list', grid('dupsrc_ext', ['name'])) }, + { type: 'view', item: viewItem('ticket', 'entry', 'form', form('ticket', ['title'])) }, + { type: 'view', item: viewItem('dupsrc_customer', 'entry', 'form', form('dupsrc_customer', ['full_name', 'dupsrc_customer'])) }, + { type: 'action', item: formAction('dupsrc_new_ticket', 'ticket', 'ticket.entry') }, + { type: 'action', item: formAction('dupsrc_new_customer', 'dupsrc_customer', 'dupsrc_customer.entry') }, + { type: 'action', item: formAction('dupsrc_new_account', 'dupext_account', 'dupext_account.entry') }, + { + type: 'permission', item: { + name: 'dupsrc_ops', label: 'Ops', + objects: { dupsrc_customer: { allowRead: true } }, + fields: { 'dupsrc_customer.dupsrc_customer': RULE, 'dupsrc_customer.x_dupsrc_customer': RULE }, + }, + }, +]; + +// ── `protocol-duplicate-package-unprefixed-object.test.ts` ───────────────── + +const TICKET_PROSE = 'A support ticket raised by a customer'; +const unprefixedCustomer: Fixture = { + type: 'object', item: { + name: 'dupsrc_customer', label: 'Customer', sharingModel: 'private', + fields: { full_name: { name: 'full_name', type: 'text', label: 'Full name' } }, + }, +}; +const unprefixed: Fixture[] = [ + unprefixedCustomer, + { + type: 'object', storedBeforeRule: true, item: { + name: 'ticket', label: 'Ticket', description: TICKET_PROSE, sharingModel: 'private', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + customer: { name: 'customer', type: 'lookup', reference: 'dupsrc_customer', label: 'Customer' }, + }, + }, + }, + { + type: 'object', item: { + name: 'dupsrc_note', label: 'Note', sharingModel: 'private', + fields: { + body: { name: 'body', type: 'text', label: 'Body' }, + ticket: { name: 'ticket', type: 'lookup', reference: 'ticket', label: 'Ticket' }, + }, + }, + }, + // The battery's container is `ticket_views`; the import names every + // container by the object it binds, so here both doors hold it as `ticket`. + { type: 'view', storedBeforeRule: true, item: { name: 'ticket', object: 'ticket', list: { label: 'All tickets', type: 'grid', columns: [{ field: 'title' }] } } }, + { + type: 'flow', item: { + name: 'dupsrc_on_ticket', label: 'On ticket', type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'ticket', triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + }, + { + type: 'permission', item: { + name: 'dupsrc_ops', label: 'Ops', + objects: { dupsrc_customer: { allowRead: true }, ticket: { allowRead: true } }, + }, + }, +]; + +// ── `protocol-duplicate-package-permission-field-keys.test.ts` ───────────── + +const HIDDEN = { readable: false, editable: false }; +const LOCKED = { readable: true, editable: false }; +const OPEN = { readable: true, editable: true }; +const fieldKeysOutside: Fixture[] = [ + { type: 'object', item: { name: 'dupext_account', label: 'Account', sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } }, +]; +const fieldKeys: Fixture[] = [ + { + type: 'object', item: { + name: 'dupsrc_customer', label: 'Customer', sharingModel: 'private', + fields: { + full_name: { name: 'full_name', type: 'text', label: 'Full name' }, + ticket: { name: 'ticket', type: 'text', label: 'Ticket reference' }, + }, + }, + }, + { + type: 'object', storedBeforeRule: true, item: { + name: 'ticket', label: 'Ticket', sharingModel: 'private', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + ticket: { name: 'ticket', type: 'text', label: 'Ticket number' }, + }, + }, + }, + { + type: 'permission', item: { + name: 'dupsrc_fls', label: 'Field rules', + objects: { + dupsrc_customer: { allowRead: true, allowEdit: true }, + ticket: { allowRead: true, allowEdit: true }, + dupext_account: { allowRead: true }, + }, + fields: { + 'dupsrc_customer.full_name': HIDDEN, + 'dupsrc_customer.ticket': LOCKED, + 'ticket.title': HIDDEN, + 'ticket.ticket': OPEN, + 'dupext_account.name': LOCKED, + }, + }, + }, +]; + +// ── `protocol-duplicate-package-objects-key-collision.test.ts` ───────────── + +const NARROW = { allowRead: true }; +const WIDE = { allowRead: true, allowEdit: true, allowDelete: true }; + +/** The import's refusal, read off the thrown error: code, status, and each refused item. */ +function refusalOf(error: any): { code: unknown; status: unknown; refused: Array<{ type: string; name: string; reason: string }> } { + expect(error, 'the import refused nothing').toBeDefined(); + return { + code: error?.code, + status: error?.status ?? error?.statusCode, + refused: error?.details?.refused ?? [], + }; +} + +/** Nothing of the copy exists: no base, no row, no history, commit or audit row naming the target. */ +function expectNothingWritten(h: Harness): void { + expect(h.engine.registry.getPackage(Q)).toBeUndefined(); + expect(rowsOf(h).filter((r) => r.package_id === Q)).toEqual([]); + const mentions = Array.from(h.stores.entries()) + .filter(([, rows]) => Array.from(rows.values()).some((row) => JSON.stringify(row).includes(`"${Q}"`))) + .map(([table]) => table); + expect(mentions).toEqual([]); +} + +describe('[#30794] the duplicate battery, replayed through the unmanaged import into `targetNamespace`', () => { + describe('the copy: both doors store the same items', () => { + it('names in step: every object, view item, form action and field rule the duplicate copies', async () => { + const dup = await viaDuplicate(namesInStep, namesInStepOutside); + const imp = await viaImport(namesInStep, namesInStepOutside); + + expect(dup.res.failed).toEqual([]); + expect(imp.error).toBeUndefined(); + expect(imp.res).toMatchObject({ targetPackageId: Q, namespace: TARGET_NS, sourceNamespace: 'dupsrc' }); + expect(itemsOf(imp.h, Q)).toEqual(itemsOf(dup.h, Q)); + + // The battery's own pins, read off the import's rows — so the parity above is not vacuous. + const customer = storedBody(imp.h, 'object', 'dupdst_customer', Q); + expect(Object.keys(customer.fields)).toEqual(['full_name', 'dupsrc_customer', 'x_dupsrc_customer']); + expect(customer.description).toBe(PROSE); + expect(storedBody(imp.h, 'object', 'dupdst_note', Q).description).toBe('Attached to dupdst_note; see x_dupsrc_note.'); + expect(storedBody(imp.h, 'object', 'dupdst_ticket', Q).fields.customer.reference).toBe('dupdst_customer'); + expect(storedBody(imp.h, 'view', 'dupdst_customer.board', Q).config.columns) + .toEqual(['full_name', 'dupsrc_customer', 'x_dupsrc_customer']); + expect(Object.keys(itemsOf(imp.h, Q)).filter((k) => k.startsWith('view/'))).toEqual([ + 'view/dupdst_customer.board', + 'view/dupdst_customer.entry', + 'view/dupdst_ticket.board', + 'view/dupdst_ticket.entry', + 'view/dupext_account.board', + 'view/dupsrc_ext.board', + ]); + expect(storedBody(imp.h, 'action', 'dupdst_new_ticket', Q)).toMatchObject({ + objectName: 'dupdst_ticket', target: 'dupdst_ticket.entry', + }); + expect(storedBody(imp.h, 'action', 'dupdst_new_account', Q).target).toBe('dupext_account.entry'); + expect(storedBody(imp.h, 'permission', 'dupdst_ops', Q).fields).toEqual({ + 'dupdst_customer.dupsrc_customer': RULE, + 'dupdst_customer.x_dupsrc_customer': RULE, + }); + // The data plane addresses the copy's column by the key the copy kept. + await imp.h.engine.insert('dupdst_customer', { id: 'c1', dupsrc_customer: 'C-1' }); + expect(imp.h.stores.get('dupdst_customer')?.get('c1')).toMatchObject({ dupsrc_customer: 'C-1' }); + }); + + it('a bare object: its copy takes the target prefix, its container follows it, and references follow; prose and a field called `ticket` do not', async () => { + const dup = await viaDuplicate(unprefixed); + const imp = await viaImport(unprefixed); + + expect(dup.res.failed).toEqual([]); + expect(imp.error).toBeUndefined(); + expect(itemsOf(imp.h, Q)).toEqual(itemsOf(dup.h, Q)); + + expect(Object.keys(itemsOf(imp.h, Q))).toEqual([ + 'flow/dupdst_on_ticket', + 'object/dupdst_customer', + 'object/dupdst_note', + 'object/dupdst_ticket', + 'permission/dupdst_ops', + // The container named by its object is named by the object's copy. + 'view/dupdst_ticket', + ]); + expect(storedBody(imp.h, 'view', 'dupdst_ticket', Q)).toMatchObject({ name: 'dupdst_ticket', object: 'dupdst_ticket' }); + const ticket = storedBody(imp.h, 'object', 'dupdst_ticket', Q); + expect(ticket.fields.customer.reference).toBe('dupdst_customer'); + expect(ticket.description).toBe(TICKET_PROSE); + const note = storedBody(imp.h, 'object', 'dupdst_note', Q); + expect(note.fields.ticket).toMatchObject({ name: 'ticket', reference: 'dupdst_ticket' }); + expect(storedBody(imp.h, 'flow', 'dupdst_on_ticket', Q).nodes[0].config.objectName).toBe('dupdst_ticket'); + expect(Object.keys(storedBody(imp.h, 'permission', 'dupdst_ops', Q).objects).sort()) + .toEqual(['dupdst_customer', 'dupdst_ticket']); + // The copy has its own table, owned by the copy. + expect(imp.h.engine.registry.getObjectOwner('dupdst_ticket')?.packageId).toBe(Q); + await imp.h.engine.insert('dupdst_ticket', { id: 't_copy', title: 'In the copy' }); + expect([...(imp.h.stores.get('dupdst_ticket')?.keys() ?? [])]).toEqual(['t_copy']); + }); + + it('a permission set\'s `fields` keys: only the object part moves, and an object outside the package keeps its name', async () => { + const dup = await viaDuplicate(fieldKeys, fieldKeysOutside); + const imp = await viaImport(fieldKeys, fieldKeysOutside); + + expect(dup.res.failed).toEqual([]); + expect(imp.error).toBeUndefined(); + expect(itemsOf(imp.h, Q)).toEqual(itemsOf(dup.h, Q)); + + expect(storedBody(imp.h, 'permission', 'dupdst_fls', Q).fields).toEqual({ + 'dupdst_customer.full_name': HIDDEN, + 'dupdst_customer.ticket': LOCKED, + 'dupdst_ticket.title': HIDDEN, + 'dupdst_ticket.ticket': OPEN, + 'dupext_account.name': LOCKED, + }); + }); + }); + + describe('the refusals: where the duplicate fails an item, the import refuses the install, before any write', () => { + it('a bare object whose copy name a prefixed sibling\'s copy claims', async () => { + const claimed: Fixture[] = [...unprefixed, { + type: 'object', item: { + name: 'dupsrc_ticket', label: 'Prefixed ticket', sharingModel: 'private', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + }, + }]; + const dup = await viaDuplicate(claimed); + expect(dup.res.failed[0]).toMatchObject({ type: 'object', name: 'ticket' }); + + const imp = await viaImport(claimed); + + const refusal = refusalOf(imp.error); + expect(refusal).toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(refusal.refused.map((r) => `${r.type}/${r.name}`)).toEqual(['object/ticket']); + const [ticket] = refusal.refused; + expect(ticket!.reason).toContain("Object 'ticket'"); + expect(ticket!.reason).toContain("'dupdst_ticket'"); + expect(ticket!.reason).toContain("'dupsrc_ticket'"); + expect(ticket!.reason).toContain("Rename 'ticket' on the source"); + expect(ticket!.reason).toMatch(/then install again\.$/); + expectNothingWritten(imp.h); + }); + + it('a permission set whose renamed `fields` key meets one it holds', async () => { + const clash: Fixture[] = [unprefixedCustomer, { + type: 'permission', item: { + name: 'dupsrc_clash', label: 'Clash', + objects: { dupsrc_customer: { allowRead: true } }, + fields: { 'dupsrc_customer.full_name': HIDDEN, 'dupdst_customer.full_name': OPEN }, + }, + }]; + const dup = await viaDuplicate(clash); + expect(dup.res.failed[0]).toMatchObject({ type: 'permission', name: 'dupsrc_clash' }); + + const imp = await viaImport(clash); + + const refusal = refusalOf(imp.error); + expect(refusal).toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(refusal.refused).toEqual([expect.objectContaining({ type: 'permission', name: 'dupsrc_clash' })]); + expect(refusal.refused[0]!.reason).toContain("Permission set 'dupsrc_clash' is not copied"); + expect(refusal.refused[0]!.reason).toContain("'dupsrc_customer.full_name'"); + expect(refusal.refused[0]!.reason).toContain("'dupdst_customer.full_name'"); + expect(refusal.refused[0]!.reason).toContain('on the source, then install again'); + expectNothingWritten(imp.h); + }); + + for (const [order, objects] of [ + ['the renamed key first', { dupsrc_customer: NARROW, dupdst_customer: WIDE }], + ['the key it meets first', { dupdst_customer: WIDE, dupsrc_customer: NARROW }], + ] as const) { + it(`a permission set whose renamed \`objects\` key meets one it holds (${order})`, async () => { + // The package body is not a stored row, so the at-rest shape the + // duplicate's battery stages after the save is simply declared. + const imp = await viaImport([unprefixedCustomer, { + type: 'permission', item: { name: 'dupsrc_objs', label: 'Objects', objects }, + }]); + + const refusal = refusalOf(imp.error); + expect(refusal).toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(refusal.refused).toEqual([expect.objectContaining({ type: 'permission', name: 'dupsrc_objs' })]); + const { reason } = refusal.refused[0]!; + expect(reason).toContain("Permission set 'dupsrc_objs' is not copied"); + expect(reason).toContain("`objects` key 'dupsrc_customer' follows the copied object to 'dupdst_customer'"); + expect(reason).toContain("Remove 'dupsrc_customer' or 'dupdst_customer' on the source, then install again."); + expectNothingWritten(imp.h); + }); + } + + it('a translation whose renamed `objects` key meets one it holds', async () => { + const h = await boot(); + let error: any; + try { + await h.protocol.importUnmanagedPackage({ + manifest: { + ...packageBody([unprefixedCustomer]), + translations: [{ 'zh-CN': { objects: { dupsrc_customer: { label: 'Customer A' }, dupdst_customer: { label: 'Customer B' } } } }], + }, + targetPackageId: Q, targetNamespace: TARGET_NS, + }); + } catch (e) { + error = e; + } + + const refusal = refusalOf(error); + expect(refusal).toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(refusal.refused).toEqual([expect.objectContaining({ type: 'translation', name: 'dupsrc_zh_cn' })]); + expect(refusal.refused[0]!.reason).toContain("Translation 'dupsrc_zh_cn' is not copied"); + expect(refusal.refused[0]!.reason).toContain("`objects` key 'dupsrc_customer' follows the copied object to 'dupdst_customer'"); + expectNothingWritten(h); + }); + + it('a hook whose renamed `object` entry meets one it holds; without the second entry it copies', async () => { + const hook = (object: string[]): Fixture => ({ + type: 'hook', item: { + name: 'dupsrc_stamp', object, events: ['beforeInsert'], + body: { language: 'js', source: "ctx.input.full_name = 'stamped';" }, + }, + }); + const imp = await viaImport([unprefixedCustomer, hook(['dupsrc_customer', 'dupdst_customer'])]); + + const refusal = refusalOf(imp.error); + expect(refusal).toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(refusal.refused).toEqual([expect.objectContaining({ type: 'hook', name: 'dupsrc_stamp' })]); + expect(refusal.refused[0]!.reason).toContain("Hook 'dupsrc_stamp' is not copied"); + expect(refusal.refused[0]!.reason).toContain("`object` entry 'dupsrc_customer' follows the copied object to 'dupdst_customer'"); + expectNothingWritten(imp.h); + + // NEGATIVE CONTROL: the same hook without the second entry copies, its entry following the object. + const again = await viaImport([unprefixedCustomer, hook(['dupsrc_customer'])]); + expect(again.error).toBeUndefined(); + expect(storedBody(again.h, 'hook', 'dupdst_stamp', Q).object).toEqual(['dupdst_customer']); + }); + }); +}); From af4a5ad9f836a6c9b42e721d492e0127fa7b5e64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:50:20 +0000 Subject: [PATCH 5/8] chore(spec): regenerate the authorable surface and the package-lifecycle reference for targetNamespace Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- content/docs/references/api/package-lifecycle.mdx | 4 +++- packages/spec/authorable-surface/api.json | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 7b2f175d8c1..56bd817e669 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -246,6 +246,7 @@ A navigation item another installed package contributes to an app the base owns, | **manifest** | `Record` | ✅ | The package to import: ONE package's assembled body, flattened — its manifest fields beside its metadata collections (`objects`, `views`, …), the shape the install door hands the manifest service. An artifact carrying `packages` is refused: import each package body on its own. | | **targetPackageId** | `string` | optional | Id of the NEW writable base the items are written into, in reverse-domain form. It must differ from the source id and name no installed package. Defaults to `-copy`. | | **targetName** | `string` | optional | Display name of the new base. Defaults to the source package's `name`. | +| **targetNamespace** | `string` | optional | The namespace the copy is renamed into: the new base's `namespace`, and the prefix every copied object name takes in place of the source's, with every reference to a renamed name rewritten (the rename `duplicatePackage` applies). Judged by `manifest.namespace`'s own declaration; a platform-reserved namespace (`base`, `system`, `sys`) is refused. Absent, the copy keeps the source package's namespace. | --- @@ -259,7 +260,8 @@ A navigation item another installed package contributes to an app the base owns, | **sourcePackageId** | `string` | ✅ | The id of the package the items were copied from. | | **sourceVersion** | `string` | optional | The version the imported body declared, when it declared one. | | **targetPackageId** | `string` | ✅ | The new writable base that owns every copied item. Nothing is registered under the source id. | -| **namespace** | `string` | ✅ | The base's namespace — the SOURCE package's, kept so every copied object name stays valid. | +| **namespace** | `string` | ✅ | The base's namespace — the request's `targetNamespace` when it named one, else the SOURCE package's, kept so every copied object name stays valid. | +| **sourceNamespace** | `string` | optional | The SOURCE package's namespace, present when the copy was renamed into `namespace` — the provenance of every copied name. Absent when the copy kept the source's namespace. | | **copiedCount** | `number` | ✅ | How many items were copied. | | **copied** | `{ type: string; name: string }[]` | ✅ | Every item that was copied, in the order it was written. | | **strippedLocks** | `{ type: string; name: string; lock: Enum<'no-overlay' \| 'no-delete' \| 'full'>; reason?: string }[]` | ✅ | Every protection lock the package shipped on a copied item and the copy does not keep, in the order the items were written. Empty when the package locked nothing. | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index ee4f8d803d0..966f6a767ec 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1008,10 +1008,12 @@ "api/ImportRowResult:warnings", "api/ImportUnmanagedPackageRequest:manifest", "api/ImportUnmanagedPackageRequest:targetName", + "api/ImportUnmanagedPackageRequest:targetNamespace", "api/ImportUnmanagedPackageRequest:targetPackageId", "api/ImportUnmanagedPackageResponse:copied", "api/ImportUnmanagedPackageResponse:copiedCount", "api/ImportUnmanagedPackageResponse:namespace", + "api/ImportUnmanagedPackageResponse:sourceNamespace", "api/ImportUnmanagedPackageResponse:sourcePackageId", "api/ImportUnmanagedPackageResponse:sourceVersion", "api/ImportUnmanagedPackageResponse:strippedLocks", From d1843f36cd712ef945bf790beecc9d6c794347ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:50:20 +0000 Subject: [PATCH 6/8] docs(deployment): an unmanaged copy can be installed under a namespace of its own Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- content/docs/deployment/install-modes.mdx | 49 ++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/content/docs/deployment/install-modes.mdx b/content/docs/deployment/install-modes.mdx index 11b2c982f03..695c751f6d1 100644 --- a/content/docs/deployment/install-modes.mdx +++ b/content/docs/deployment/install-modes.mdx @@ -92,6 +92,7 @@ runtime installs managed. The door judges the mode before it writes anything: | A mode the package's `installModes` does not permit | `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits | | `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`) | `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture | | `managed`, while an unmanaged copy of the package holds its namespace | `409 RESOURCE_CONFLICT`, naming the copy's base | +| `targetNamespace` on a `managed` install, or with no `installMode` | `400 INVALID_REQUEST`: a managed install is never renamed | A runtime that predates install modes ignores the field and installs the package managed. If you asked for `unmanaged`, the CLI says so and exits `1`. @@ -144,7 +145,8 @@ delete, and `full` both. ## What an unmanaged copy keeps, and what it drops The package's metadata is copied **once** into a new writable base, -`-copy`, which keeps the package's namespace. Every item is written +`-copy`, which keeps the package's namespace unless the install +[names another](#installing-the-copy-under-a-namespace-of-its-own). Every item is written through the save door a Studio edit takes, environment-wide, and is served as environment-authored metadata: the next save edits it. @@ -215,6 +217,51 @@ dash and reason left off when there is none: Read it before you edit: it is why the vendor locked each item. +### Installing the copy under a namespace of its own + +An unmanaged install can rename the copy into a namespace you name, so it sits +beside the package, or beside another copy of it, under names of its own. Send +`targetNamespace` with `installMode: 'unmanaged'` in the body of +`POST /api/v1/marketplace/install-local` (the CLI has no flag for it): + +```json +{ "packageId": "com.acme.crm", "versionId": "1.2.0", "installMode": "unmanaged", "targetNamespace": "acme" } +``` + +The copy is renamed by the same rules as duplicating a package: + +- **Every object takes the new namespace.** `crm_lead` copies as `acme_lead`, + and an object whose name lacks the package's prefix takes the new one in + front (`ticket` copies as `acme_ticket`). +- **What names an object follows it.** A lookup's `reference`, a view's + `object`, a permission set's `objects` keys and the object part of its + `fields` keys, a translation's object keys, a form action's view target. + A view item follows its object (`crm_lead.board` copies as + `acme_lead.board`), and so does a view container and every view it expands + into. A field keeps its key, and a word in free text is renamed only when it + is a whole prefixed object name (`crm_lead`, never `x_crm_lead`). +- **Translations are named `acme_`**, with the object keys inside them + renamed. +- **An object of another package keeps its name**, and so do its view items. + +`targetNamespace` follows the rule for a manifest `namespace`, and the +platform's shared namespaces `base`, `system` and `sys` are refused. Every +refusal is answered before the base exists, so nothing is installed: + +| The rename | The answer | +|:---|:---| +| A `targetNamespace` the namespace rule refuses, or a shared one | `400 INVALID_REQUEST`, naming `targetNamespace` | +| An object whose copy name another object's copy takes (`ticket` beside `crm_ticket`) | `400 INVALID_REQUEST`, naming the object and the rename that clears it | +| A copy name that is not a legal item name, longer than an object's table name may be (63 characters), or longer than an item name may be (255) | `400 INVALID_REQUEST`, naming each item | +| A renamed key that meets a key the item already holds (a permission set granting both `crm_lead` and `acme_lead`) | `400 INVALID_REQUEST`, naming the item and both keys | +| A renamed name the environment or a managed package already holds | `409 RESOURCE_CONFLICT`, naming each | +| A `targetNamespace` another package owns | `409 RESOURCE_CONFLICT`, naming it | + +The install answer's `installedFrom` gains `namespace`, the package's own, +beside its id and version, so the answer records the rename it applied: names +in `installedFrom.namespace` were copied into the copy's `namespace`. Without +`targetNamespace` the answer is unchanged. + --- ## Choosing what to permit From d2397f322058abc44e74fa58eb16f0e1dcb08afa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 04:50:37 +0000 Subject: [PATCH 7/8] chore(changeset): an unmanaged install can take a target namespace Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- .../30794-unmanaged-install-target-namespace.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/30794-unmanaged-install-target-namespace.md diff --git a/.changeset/30794-unmanaged-install-target-namespace.md b/.changeset/30794-unmanaged-install-target-namespace.md new file mode 100644 index 00000000000..6b83c524532 --- /dev/null +++ b/.changeset/30794-unmanaged-install-target-namespace.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': patch +'@objectstack/cloud-connection': patch +--- + +feat(metadata-protocol,cloud-connection): an unmanaged install can take a target namespace + +Clause-②: yes (widening) +Metadata-protocol: added + +- **An unmanaged copy can be installed under a namespace of its own.** `importUnmanagedPackage` and `POST /api/v1/marketplace/install-local` (with `installMode: 'unmanaged'`) take an optional `targetNamespace`. The copy's base takes it as its `namespace`, every copied object name takes it in place of the package's (a name without the package's prefix takes it in front), and every reference to a renamed name follows. Absent, the copy keeps the package's namespace exactly as before. +- **One rename, two doors.** The copy is renamed by the rules `duplicatePackage` already applies, now one function both doors call: view items and view containers follow their object, translations are named `_` with the object keys inside them renamed, a field keeps its key, and free text is renamed only at a whole prefixed object name. +- **Refused before anything is installed.** A `targetNamespace` the `manifest.namespace` rule refuses, or a platform-reserved one (`base`, `system`, `sys`), answers `400 INVALID_REQUEST`. So does a copy the names cannot make: an object whose copy name another object's copy takes, a copy name that is not a legal item name or is longer than its storage allows (63 characters for an object's table, 255 for an item name), and a renamed key that meets a key the item already holds — each item named. The same-name check (`409 RESOURCE_CONFLICT`) is judged on the renamed names, and a `targetNamespace` another package owns is refused `409`. +- **A managed install is never renamed.** `targetNamespace` beside a managed install (or no `installMode`) answers `400 INVALID_REQUEST` before anything is fetched. +- **The rename is recorded.** The import's answer carries `sourceNamespace`, and the install-local answer's `installedFrom` carries the package's own `namespace` beside its id and version. Neither appears when the copy kept the package's namespace. +- **A bare object's view container follows it on a duplicate too.** A view named by an object of the package takes that object's copy name, so `duplicatePackage` no longer leaves a bare object's container under the name the source's container holds. From a39d3108790bca29ff42fe3de60bbb2079a01bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:20:57 +0000 Subject: [PATCH 8/8] test(metadata-protocol): unmanaged-install test pays the lint root at collection The source-reach half of check:test-source-alias (on main since #30969) judges this edited file: its cases reach the protocol's lazy import('@objectstack/lint') (metadata-diagnostics.ts) inside a clocked window. Import ./lint-root-preload.testkit.js at module top, as the sibling protocol test files do. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H --- .../metadata-protocol/src/package-unmanaged-install.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index 86e80d96b2d..fcf9c312327 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -30,6 +30,8 @@ * door, the REAL install primitive and the REAL uninstall run against it; its * write verbs route through the producer's own dispatch predicates. */ +// Pays the lint root's first load at collection, never inside a clocked case. +import './lint-root-preload.testkit.js'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ImportUnmanagedPackageResponseSchema } from '@objectstack/spec/api';