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. 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 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/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/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 975487202cf..ab49872d8ab 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 @@ -1841,6 +1849,25 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 400); } + // 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; @@ -2233,6 +2260,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { userId, withheldMaps, protocolCheckSkipped, + ...(targetNamespace !== undefined ? { targetNamespace } : {}), }); } @@ -4007,9 +4035,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') { @@ -4029,6 +4059,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) { @@ -4059,13 +4090,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.test.ts b/packages/metadata-protocol/src/package-namespace-rename.test.ts new file mode 100644 index 00000000000..92bc4cf3573 --- /dev/null +++ b/packages/metadata-protocol/src/package-namespace-rename.test.ts @@ -0,0 +1,95 @@ +// 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('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', + }); + 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' } } }, + }); + }); +}); 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..06314a02044 --- /dev/null +++ b/packages/metadata-protocol/src/package-namespace-rename.ts @@ -0,0 +1,519 @@ +// 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, 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 isViewType(type) && 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) }; + }; + + // [#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) + : isViewType(type) && renameMap.has(name) + ? renameMap.get(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..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'; @@ -876,3 +878,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 524fb1f12c1..5948b71a588 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 { @@ -270,307 +275,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): @@ -593,7 +322,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 }); @@ -663,16 +392,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; @@ -735,23 +460,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 @@ -763,19 +482,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({ @@ -872,6 +578,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 @@ -880,7 +589,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. @@ -897,34 +606,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( @@ -934,6 +667,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); @@ -941,8 +675,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 } }); } @@ -979,6 +717,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 b6a184beb8e..38475af4ac5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11492,7 +11492,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. 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']); + }); + }); +}); 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", 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({