diff --git a/.changeset/29594-pair-lookup-installed-kernel-verdict.md b/.changeset/29594-pair-lookup-installed-kernel-verdict.md new file mode 100644 index 00000000000..5dd1dadc139 --- /dev/null +++ b/.changeset/29594-pair-lookup-installed-kernel-verdict.md @@ -0,0 +1,29 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/cli': patch +'@objectstack/cloud-connection': patch +'@objectstack/lint': patch +'@objectstack/service-automation': patch +'@objectstack/service-datasource': patch +--- + +feat(metadata-protocol, metadata-core)!: the ensure door creates no customization pair for a kernel package, and every pair question carries the installed manifests + +Clause-②: yes (narrowing) — both arms. Narrowing: `POST /api/v1/packages/:id/ensure-customization` (and the protocol's `ensureCustomizationPackage`) answers a kernel base, a package of scope `system` or `cloud`, with `409 RESOURCE_CONFLICT` and a sentence saying kernel objects are not customizable in this version, where it created `.custom`; `POST /api/v1/automation/:name/clone` of a flow a kernel package ships, with no `package` named, answers that `409` where it answered `200` with the copy in `.custom`; and the exported `pairOf`, `isPairOf` and `appOf` (`@objectstack/metadata-core`), `isServedOverrideRowOf` and the `installed` member of `OverrideWriteClosure` (`@objectstack/metadata-protocol`), and `resolveFlowPrecedence` / `describeFlowContender` (`@objectstack/service-automation`) take the installed-manifest lookup as a required argument. Widening: five new exports, `installedManifestsOf`, `InstalledManifestLookup`, `AppLookupManifest` and `InstalledPackageSource` (`@objectstack/metadata-core`) and `kernelPackageScope` (`@objectstack/metadata-protocol`), and one new `noticeCode` value on the permission-set clone answer, `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`, for a copy of a kernel-shipped set that now lands environment-wide. +Metadata-protocol: none + + + +**BREAKING** for a caller that ensures the customization pair of a kernel package — a platform package of scope `system` (users, organizations, security, settings and the rest of the platform suite) or `cloud` — and for a TypeScript importer of the pair helpers named above. + +- **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime, at the runtime's version (installing it whole is the goal ruling 6691022506 records, not yet what every `serve` tier does). In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision. +- **What the door answers.** `409 RESOURCE_CONFLICT`, the status and code the door already answers when a base's state rules the pair out (a writable base, no namespace, a version that is not SemVer). Nothing is created or written. Every other base is answered exactly as before. +- **A kernel pair that already exists stays.** An earlier version of the door may have created `.custom` on a deployment. It stays installed, the overrides inside it stay served by every read of the kernel item's name, and a further save into it is still admitted. Only the ensure call is refused. +- **A copy of a platform permission set.** `POST /api/v1/security/permission-sets/:name/clone` with no `package` named landed the copy of a kernel-shipped set (`member_default` and the platform's other sets) in `.custom`. It now saves the copy environment-wide, as it already does for a set authored in the environment, with the notice code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE` and a sentence naming the kernel package and `package`. A Console that does not know the new code shows that sentence as sent. Naming a writable package with `package` still lands the copy there. The flow clone door answers a kernel-shipped flow with the ensure door's sentence. +- **The closes stay shut on a kernel item.** A package-less write, or a write naming the kernel package, onto an item a kernel package ships is still refused `403 NOT_OVERRIDABLE` exactly where it was refused before (ADR-0146 D11). The sentence no longer names the pair as the fix, since there is none to ensure: it says kernel objects are not customizable in this version. A later version's kernel whitelist is what opens such an item. +- **The lookup.** Which pair customizes a package is asked through the installed manifests of the registry the caller serves (`installedManifestsOf(registry)`). Today the answer is unchanged for every package: `appOf` is still the identity. The argument is threaded through every site now so that resolving a module to its App is one change in `appOf`. +- **`os validate`'s customization-namespace advisory** now skips a package that is itself a pair through `isCustomizationPackage`, the one spelling of that question. An id ending in `.custom` that no base derives (`a.custom.custom`, a bare `.custom`) is now judged like any other package. +- **Who moves, and the fix.** A script or client that ensured a kernel package's pair drops that call: the kernel item is served as it ships, and an existing pair keeps serving what it holds. A vendor package that declares `scope: 'system'` or `'cloud'` in its own manifest is read as kernel here, as `isWritablePackage` already reads it; its remedy is to drop the scope, which belongs to the platform's own packages. A TypeScript importer of the helpers passes `installedManifestsOf(engine.registry)`, or `installedManifestsOf(undefined)` where it holds no registry. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 3e7b62b00cf..f3880d37198 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -111,7 +111,9 @@ capability changes only with a new version of the package that declares it. An sentence names that type's own route. What the reads then serve is in [Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146). `POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls -it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides, +it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this +version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps +serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides, extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`) and flows (kind `flow`) other installed packages contribute to `hotcrm`'s objects, and the navigation items (kind `nav`) they contribute to `hotcrm`'s apps, each entry naming its package. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 44593fe05f7..17e11e909ae 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -182,7 +182,7 @@ document **whole** for every read that serves the base's name > **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. -> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view. +> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)); for an item a kernel package (scope `system` or `cloud`) ships, the same write is refused with a sentence that names no pair, because kernel objects are not customizable in this version and a kernel package has no pair. A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view. | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | @@ -213,9 +213,9 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | Type | Sanctioned path the refusal names | | :--- | :--- | -| `flow` | Declare an override from the base's paired `.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | +| `flow` | Declare an override from the base's paired `.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use; a kernel package, of scope `system` or `cloud`, has no pair in this version, so a copy of a flow it ships answers the ensure door's `409` until the body names `package`), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | -| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom` (ADR-0146 D3/D6). | +| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom`, and a copy of a set a kernel package ships lands environment-wide (ADR-0146 D3/D6). | A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 112c703ff22..c8472c0d4c8 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -453,7 +453,11 @@ the source never reach the copy. package `.custom`, the package that holds everything you changed about X. The pair is created on the first copy, by the same ensure door as `POST /api/v1/packages/:id/ensure-customization` (its only creation path), - and reused after. A copy of a set already in a writable package is + and reused after. A copy of a set the platform itself ships (a kernel + package of scope `system` or `cloud`, such as `member_default`) is saved + environment-wide, in no package: a kernel package has no paired + customization package in this version, and the notice says so + (`noticeCode` `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`). A copy of a set already in a writable package is saved in that package, and a copy of a set authored in this system stays environment-wide, in no package. With `package`, the copy is saved there: it must be an installed, writable package (else `422` diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts index 287ec5bac79..bd874bee906 100644 --- a/packages/cli/src/utils/override-declarations.test.ts +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from 'vitest'; import { judgeOverrideWrite } from '@objectstack/metadata-protocol'; import { findOverrideDeclarationRefusals } from './override-declarations.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; const BASE = 'com.acme.crm'; const PAIR = 'com.acme.crm.custom'; @@ -33,7 +34,7 @@ const base = pkg(BASE, [], { }); const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => { - const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers }); + const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers, installed: installedManifestsOf(undefined) }); if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`); return v.message; }; diff --git a/packages/cli/src/utils/override-declarations.ts b/packages/cli/src/utils/override-declarations.ts index 9868c00fa50..46376a15495 100644 --- a/packages/cli/src/utils/override-declarations.ts +++ b/packages/cli/src/utils/override-declarations.ts @@ -38,6 +38,7 @@ import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol'; import { OverrideDeclarationSchema } from '@objectstack/spec/kernel'; +import type { InstalledManifestLookup } from '@objectstack/metadata-core'; import { artifactPackages } from './artifact-packages.js'; @@ -56,6 +57,8 @@ export interface OverrideDeclarationRefusalRow { interface WalkedPackage { id: string; + /** The package's manifest as the artifact carries it. */ + manifest: Record | undefined; dependencies: string[]; items: OverrideWriteItem[]; } @@ -75,6 +78,7 @@ function packagesOf(parsed: Record): WalkedPackage[] { if (entries.length > 0) { return entries.map(({ index, id, body }) => ({ id, + manifest: body, dependencies: dependencyIds(body), items: overrideWriteItemsOf(body, `packages[${index}].manifest.`), })); @@ -82,7 +86,7 @@ function packagesOf(parsed: Record): WalkedPackage[] { const manifest = isRecord(parsed.manifest) ? parsed.manifest : undefined; const id = typeof manifest?.id === 'string' && manifest.id !== '' ? manifest.id : undefined; if (id === undefined) return []; - return [{ id, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }]; + return [{ id, manifest, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }]; } /** `id`'s dependency closure, followed through the packages this artifact carries. */ @@ -107,6 +111,9 @@ export function findOverrideDeclarationRefusals(parsed: Record) const packages = packagesOf(parsed); const byId = new Map(packages.map((p) => [p.id, p])); const shipped = new Map(packages.map((p) => [p.id, new Set(p.items.map((i) => `${i.type}\u0000${i.identity}`))])); + // The manifests this artifact carries, as the pair lookup reads installed + // ones: who may declare an override of a base is its App's pair. + const installed: InstalledManifestLookup = (packageId) => byId.get(packageId)?.manifest; const rows: OverrideDeclarationRefusalRow[] = []; for (const pkg of packages) { const dependencies = closureOf(pkg.id, byId); @@ -133,7 +140,7 @@ export function findOverrideDeclarationRefusals(parsed: Record) (byId.get(baseId)?.items ?? []).filter((i) => i.type === walked.type).map((i) => i.item); const verdict = judgeOverrideWrite( { type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item }, - { dependencies, shippers, baseItemOf, baseItemsOf }, + { dependencies, shippers, baseItemOf, baseItemsOf, installed }, ); if (verdict.verdict !== 'refuse') continue; rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message }); diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts index 883b699ba58..affc552730e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -32,6 +32,7 @@ import '@objectstack/runtime'; import { judgeOverrideWrite } from '@objectstack/metadata-protocol'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; type Handler = (c: any) => Promise; @@ -163,7 +164,7 @@ describe('install-local 2d — the override write gate at the managed install (A expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE'); const verdict = judgeOverrideWrite( { type: 'dashboard', name: 'crm_board', packageId: PARTNER, item }, - { dependencies: [BASE], shippers: [BASE] }, + { dependencies: [BASE], shippers: [BASE], installed: installedManifestsOf(undefined) }, ); expect(res.payload.error.details.refusals).toEqual([ { type: 'dashboard', name: 'crm_board', rule: 'undeclared_same_name', message: (verdict as any).message }, diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ab49872d8ab..4440b056fbe 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -245,6 +245,10 @@ import { fieldPatchTargetRefusals, fieldPatchTargetRefusalMessage, type FieldPatchTargetRefusal, + // [ADR-0146 D3 · ruling record 6691022506] The installed manifests the + // override gate's pair lookup reads. + installedManifestsOf, + type InstalledManifestLookup, } from '@objectstack/metadata-core'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; @@ -3831,7 +3835,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // each under its own id. A delivered package is in no registry yet: its // dependencies and its items are read off the body, and it registers // read-only, so it is a base a sibling's closure can hold. - const delivered = new Map(); + const delivered = new Map(); const entries: Record[] = Array.isArray(converted.packages) ? converted.packages .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined)) @@ -3840,8 +3844,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { for (const body of entries) { const id = entries.length === 1 && !Array.isArray(converted.packages) ? manifestId : manifestIdOf(body); if (typeof id !== 'string' || id === '') continue; - delivered.set(id, { dependencies: dependencyIdsOf(declaredPackageManifest(body)), items: overrideWriteItemsOf(body) }); + const manifest = declaredPackageManifest(body); + delivered.set(id, { manifest, dependencies: dependencyIdsOf(manifest), items: overrideWriteItemsOf(body) }); } + // The manifests the pair lookup reads: a delivered package's own, + // else the installed one — who may declare an override of a base is + // its App's pair (`pairOf`). + const installedRegistry = installedManifestsOf(registry); + const manifestOf: InstalledManifestLookup = (id) => { + const local = delivered.get(id)?.manifest; + return isPlainObject(local) ? local : installedRegistry(id); + }; const dependenciesOf = (id: string): string[] => delivered.get(id)?.dependencies ?? dependencyIdsOf((registry.getPackage?.(id) as { manifest?: unknown } | undefined)?.manifest); const closureOf = (id: string): string[] => { @@ -3920,7 +3933,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } const verdict = judgeOverrideWrite( { type: item.type, name: item.name, packageId, item: item.item }, - { dependencies: closure, shippers, baseItemOf: (base) => shipped.get(base), baseItemsOf: (base) => listed.get(base) ?? [] }, + { + dependencies: closure, shippers, baseItemOf: (base) => shipped.get(base), + baseItemsOf: (base) => listed.get(base) ?? [], installed: manifestOf, + }, ); if (verdict.verdict === 'refuse') { refusals.push({ @@ -3946,7 +3962,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (holding === undefined) continue; const verdict = judgeOverrideWrite( { type: item.type, name: item.name, packageId: dependent, item: holding }, - { dependencies: dependentClosure, shippers: [baseId] }, + { dependencies: dependentClosure, shippers: [baseId], installed: manifestOf }, ); if (verdict.verdict === 'refuse') { collisions.push({ type: item.type, name: item.name, packageId: dependent, rule: verdict.rule, message: verdict.message }); diff --git a/packages/lint/src/validate-customization-namespace.ts b/packages/lint/src/validate-customization-namespace.ts index 51500056606..3c04712027c 100644 --- a/packages/lint/src/validate-customization-namespace.ts +++ b/packages/lint/src/validate-customization-namespace.ts @@ -33,8 +33,8 @@ * * ## Not judged here * - * - A package that is itself a pair (`customizationPackageId` answers - * `base_is_customization_package`): a pair is never paired again, and its + * - A package that is itself a pair (`isCustomizationPackage`, the one + * spelling of "some base derives this id"): a pair is never paired again, and its * namespace is the pair namespace, longer than a base's by construction. * - The helper's other two refusals. A missing namespace is not this rule's * question, and a namespace the manifest grammar refuses for another reason @@ -53,7 +53,7 @@ import { CUSTOMIZATION_NAMESPACE_SUFFIX, MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, customizationNamespace, - customizationPackageId, + isCustomizationPackage, } from '@objectstack/spec/kernel'; import type { AnyRec } from './page-walk.js'; @@ -105,8 +105,7 @@ export function validateCustomizationNamespace(stack: AnyRec): CustomizationName if (!isRec(stack)) return findings; for (const { manifest, at } of deliveredManifests(stack)) { - const pairId = customizationPackageId(manifest.id); - if (!pairId.ok && pairId.reason === 'base_is_customization_package') continue; + if (isCustomizationPackage(manifest.id)) continue; const pairNamespace = customizationNamespace(manifest.namespace); if (pairNamespace.ok || pairNamespace.reason !== 'base_namespace_too_long') continue; diff --git a/packages/metadata-core/src/customization-pair-lookup.test.ts b/packages/metadata-core/src/customization-pair-lookup.test.ts index 440ca1adca3..edb009b539f 100644 --- a/packages/metadata-core/src/customization-pair-lookup.test.ts +++ b/packages/metadata-core/src/customization-pair-lookup.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel'; -import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js'; +import { + appOf, + installedManifestsOf, + isPairOf, + pairOf, + type InstalledManifestLookup, +} from './customization-pair-lookup.js'; /** * The lookup answers exactly what the pure id rule answered for every package @@ -10,10 +16,16 @@ import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js'; * package, a `sys` kernel package, a manifest with no namespace — because * `appOf` is the identity until the ensure door resolves a member to its App. * Each row pins the literal pair, so a resolver that moves one package to - * another App reds that row by name. The rows' `type`, `scope` and `namespace` - * are read by nothing today: they are the App resolver's inputs when it lands. + * another App reds that row by name. Every row is asked twice: with nothing + * installed, and with all the rows installed — the module declaring its App + * the way HotCRM's modules do — so the lookup the callers now hand in is + * proven to move nothing while `appOf` stays the identity. */ -const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string; scope?: string; namespace?: string }; pair: string }> = [ +const ROWS: ReadonlyArray<{ + shape: string; + manifest: { id: string; type?: string; scope?: string; namespace?: string; dependencies?: Record }; + pair: string; +}> = [ { shape: 'an App package', manifest: { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' }, @@ -21,7 +33,12 @@ const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string }, { shape: 'a module an App artifact brings (still its own pair today)', - manifest: { id: 'app.objectstack.hotcrm.service', type: 'module', namespace: 'crm' }, + manifest: { + id: 'app.objectstack.hotcrm.service', + type: 'module', + namespace: 'crm', + dependencies: { 'app.objectstack.hotcrm': '^6.1.0' }, + }, pair: 'app.objectstack.hotcrm.service.custom', }, { @@ -41,11 +58,43 @@ const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string }, ]; +const NOTHING_INSTALLED = installedManifestsOf(undefined); +const ALL_INSTALLED = installedManifestsOf({ + getPackage: (id: string) => { + const row = ROWS.find((r) => r.manifest.id === id); + return row ? { manifest: row.manifest } : undefined; + }, +}); +const LOOKUPS: ReadonlyArray<{ installed: string; lookup: InstalledManifestLookup }> = [ + { installed: 'nothing installed', lookup: NOTHING_INSTALLED }, + { installed: 'every row installed', lookup: ALL_INSTALLED }, +]; +const ROWS_BY_LOOKUP = LOOKUPS.flatMap(({ installed, lookup }) => ROWS.map((row) => ({ ...row, installed, lookup }))); + +describe('installedManifestsOf — the registry read the lookup is', () => { + it('reads the installed manifest by id, and nothing for an id not installed', () => { + const service = ROWS[1]!.manifest; + expect(ALL_INSTALLED(service.id)).toBe(service); + expect(ALL_INSTALLED('com.example.absent')).toBeUndefined(); + }); + + it.each([ + { source: 'no registry', registry: undefined }, + { source: 'a null registry', registry: null }, + { source: 'a registry with no getPackage', registry: {} }, + { source: 'a package with no manifest', registry: { getPackage: () => ({}) } }, + { source: 'a manifest that is not an object', registry: { getPackage: () => ({ manifest: 'app' }) } }, + { source: 'a null manifest', registry: { getPackage: () => ({ manifest: null }) } }, + ])('answers undefined for $source', ({ registry }) => { + expect(installedManifestsOf(registry)('app.objectstack.hotcrm')).toBeUndefined(); + }); +}); + describe('pairOf — the pair of a package, looked up through its App', () => { - it.each(ROWS)('$shape: answers the old derivation, $pair', ({ manifest, pair }) => { - expect(appOf(manifest.id)).toBe(manifest.id); - expect(pairOf(manifest.id)).toEqual(customizationPackageId(manifest.id)); - expect(pairOf(manifest.id)).toEqual({ ok: true, value: pair }); + it.each(ROWS_BY_LOOKUP)('$shape, $installed: answers the old derivation, $pair', ({ manifest, pair, lookup }) => { + expect(appOf(manifest.id, lookup)).toBe(manifest.id); + expect(pairOf(manifest.id, lookup)).toEqual(customizationPackageId(manifest.id)); + expect(pairOf(manifest.id, lookup)).toEqual({ ok: true, value: pair }); }); it.each([ @@ -55,24 +104,26 @@ describe('pairOf — the pair of a package, looked up through its App', () => { { input: 'hotcrm.custom', reason: 'base_is_customization_package' }, { input: 'Com.Acme', reason: 'base_id_invalid' }, ])('refuses $input as the id rule does: $reason', ({ input, reason }) => { - expect(pairOf(input)).toEqual(customizationPackageId(input)); - expect(pairOf(input)).toEqual({ ok: false, reason }); + for (const { lookup } of LOOKUPS) { + expect(pairOf(input, lookup)).toEqual(customizationPackageId(input)); + expect(pairOf(input, lookup)).toEqual({ ok: false, reason }); + } }); }); describe('isPairOf — is this THE pair of that package', () => { - it.each(ROWS)('$shape: only $pair is its pair', ({ manifest, pair }) => { + it.each(ROWS_BY_LOOKUP)('$shape, $installed: only $pair is its pair', ({ manifest, pair, lookup }) => { const candidates = [pair, manifest.id, `${pair}.custom`, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined]; for (const candidate of candidates) { - expect(isPairOf(candidate, manifest.id)).toBe(isCustomizationPairOf(candidate, manifest.id)); + expect(isPairOf(candidate, manifest.id, lookup)).toBe(isCustomizationPairOf(candidate, manifest.id)); } - expect(isPairOf(pair, manifest.id)).toBe(true); - expect(isPairOf(manifest.id, manifest.id)).toBe(false); + expect(isPairOf(pair, manifest.id, lookup)).toBe(true); + expect(isPairOf(manifest.id, manifest.id, lookup)).toBe(false); }); - it('a pair has no pair, and a missing base has none', () => { - expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom')).toBe(false); - expect(isPairOf('.custom', '')).toBe(false); - expect(isPairOf('x.custom', undefined)).toBe(false); + it.each(LOOKUPS)('$installed: a pair has no pair, and a missing base has none', ({ lookup }) => { + expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom', lookup)).toBe(false); + expect(isPairOf('.custom', '', lookup)).toBe(false); + expect(isPairOf('x.custom', undefined, lookup)).toBe(false); }); }); diff --git a/packages/metadata-core/src/customization-pair-lookup.ts b/packages/metadata-core/src/customization-pair-lookup.ts index bd176c7702a..77f87a5e289 100644 --- a/packages/metadata-core/src/customization-pair-lookup.ts +++ b/packages/metadata-core/src/customization-pair-lookup.ts @@ -23,11 +23,15 @@ * `customizationBasePackageId`, `isCustomizationPackage`) is unaffected and * stays with the spec helpers. * - * Today {@link appOf} is the identity: every package is its own App for + * Every question carries the caller's {@link InstalledManifestLookup} — the + * installed manifests of the registry it serves ({@link installedManifestsOf}), + * the only record of which App a member belongs to. Today {@link appOf} reads + * none of it and is the identity: every package is its own App for * customization, so {@link pairOf} answers exactly what * `customizationPackageId` answers, refusals included. Resolving a member - * package to the App whose artifact brought it is the ensure door's change - * (ADR-0146 D3 as amended), not this module's. + * package to its App from that lookup is the next change (ADR-0146 D3 as + * amended, merge question 11), and it lands in {@link appOf} alone because + * every caller already hands the lookup in. * * Why this package: every caller that asks the question — * `@objectstack/metadata-protocol`, `@objectstack/objectql`, @@ -44,15 +48,62 @@ import { type CustomizationPackageIdRefusal, } from '@objectstack/spec/kernel'; +/** + * What the App lookup may read of an installed package: its manifest's + * `type`, `scope`, `namespace` and `dependencies` — the declarations the + * registry holds (ADR-0146 D3 as amended, merge question 11: nothing in the + * tree persists which artifact delivered a package, so the App of a package + * is read from what the installed packages declare). + */ +export interface AppLookupManifest { + readonly type?: unknown; + readonly scope?: unknown; + readonly namespace?: unknown; + readonly dependencies?: unknown; +} + +/** + * The installed manifest of a package id, or `undefined` when no package of + * that id is installed — a registry's `getPackage(id)?.manifest`. Every + * caller of {@link appOf}, {@link pairOf} and {@link isPairOf} passes the + * lookup of the registry it serves; {@link installedManifestsOf} builds one. + */ +export type InstalledManifestLookup = (packageId: string) => AppLookupManifest | undefined; + +/** A registry, as far as {@link installedManifestsOf} reads it. */ +export interface InstalledPackageSource { + getPackage?(id: string): { readonly manifest?: unknown } | undefined; +} + +/** The lookup of a registry holding nothing: every package is absent. */ +const NOTHING_INSTALLED: InstalledManifestLookup = () => undefined; + +/** + * The {@link InstalledManifestLookup} over `registry`'s installed packages. A + * missing registry (or one without `getPackage`) holds nothing, so every + * package reads as absent — and an absent package is its own unit, which is + * what a lookup with no registry to read can truthfully answer. + */ +export function installedManifestsOf(registry: InstalledPackageSource | null | undefined): InstalledManifestLookup { + if (!registry || typeof registry.getPackage !== 'function') return NOTHING_INSTALLED; + return (packageId) => { + const manifest = registry.getPackage!(packageId)?.manifest; + return typeof manifest === 'object' && manifest !== null ? (manifest as AppLookupManifest) : undefined; + }; +} + /** * The App that carries `packageId` for customization: the id whose pair holds - * every customization of `packageId`'s items. + * every customization of `packageId`'s items, read through `installed` (the + * registry the caller serves). * - * Today the identity — every package is its own App for this purpose. The - * lookup is the seam the App-unit ruling moves; its callers never assume the - * identity. + * Today the identity — every package is its own App for this purpose, and + * `installed` is not read yet. The lookup is the seam the App-unit ruling + * moves; its callers never assume the identity, and each already hands it the + * registry it will read. */ -export function appOf(packageId: string): string { +export function appOf(packageId: string, installed: InstalledManifestLookup): string { + void installed; return packageId; } @@ -61,8 +112,8 @@ export function appOf(packageId: string): string { * it, or the refusal `customizationPackageId` names (`base_id_missing`, * `base_is_customization_package`, `base_id_invalid`). */ -export function pairOf(base: unknown): CustomizationDerivation { - return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base) : base); +export function pairOf(base: unknown, installed: InstalledManifestLookup): CustomizationDerivation { + return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base, installed) : base); } /** @@ -70,7 +121,7 @@ export function pairOf(base: unknown): CustomizationDerivation, singularType: string, + static restoreWriteFor(host: Pick, singularType: string, name: string, binding: string | null, artifactBacked: boolean, source: string,): { intent: 'override-artifact' | 'runtime-only'; deriveRestoredBody: (historyBody: unknown) => Promise } { const owner = artifactBacked ? host.packagedArtifactOwner({ type: singularType, name }) : undefined; - const pairRow = owner !== undefined && isPairOf(binding, owner); + const pairRow = owner !== undefined && isPairOf(binding, owner, installedManifestsOfEngine(host.engine)); const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRow ? 'override-artifact' : 'runtime-only'; const writeRestored = host.restoredBodyWriter(singularType, name, binding, source); const deriveRestoredBody = pairRow diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 34d023f6178..34444f6fcb5 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -60,7 +60,10 @@ * - `400` — the request is malformed, or the base id has no pair (it is * itself a pair, or `.custom` is not a valid package id); * - `404` — the base is not installed; - * - `409` — the base's state rules the pair out: the base is writable + * - `409` — the base's state rules the pair out: the base is a kernel + * package ({@link kernelPackageScopeOf}), whose objects are not + * customizable in this version (ruling record 6691217245 on ADR-0146 D3); + * the base is writable * (customize it directly), declares no namespace, or has a namespace whose * pair namespace fails the manifest grammar (longer than * {@link MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH}, no truncation and no @@ -89,7 +92,7 @@ */ import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { isWritablePackage } from './package-writability.js'; +import { isWritablePackage, READ_ONLY_PACKAGE_SCOPES } from './package-writability.js'; import { EnsureCustomizationPackageRequestSchema } from '@objectstack/spec/api'; import type { EnsureCustomizationPackageRequest, @@ -103,6 +106,7 @@ import { customizationNamespace, } from '@objectstack/spec/kernel'; import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import type { CustomizationNamespaceRefusal } from '@objectstack/spec/kernel'; /** The HTTP route that serves the door — named by every refusal that points at it. */ @@ -141,9 +145,39 @@ interface PairPackageRow { version?: unknown; namespace?: unknown; dependencies?: unknown; + scope?: unknown; }; } +/** + * [ADR-0146 D3, ruling record 6691217245] The kernel scope of an installed + * package — `system` or `cloud`, the scopes the platform delivers + * ({@link READ_ONLY_PACKAGE_SCOPES}) — or `undefined` for any other package. + * The kernel is one suite that ships with the runtime at the runtime's version + * (installing it whole is the goal ruling 6691022506 records), so it is no + * customer's unit to customize: in + * this version every kernel object is served as not customizable, and the + * ensure door creates no pair for a kernel package. Which kernel objects a + * later version opens to customization (a whitelist) is that version's + * question, not this predicate's. + */ +function kernelPackageScopeOf(row: PairPackageRow | undefined): string | undefined { + const scope = row?.manifest?.scope; + return typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope) ? scope : undefined; +} + +/** + * [ruling record 6691217245] The kernel scope of `packageId` as `engine`'s + * registry holds it ({@link kernelPackageScopeOf}), or `undefined` for a + * package that is not a kernel package or is not installed. The door's own + * reading, for the callers that land something in a package's pair and must + * know beforehand that a kernel package has none (the "new similar" doors). + */ +export function kernelPackageScope(engine: unknown, packageId: string): string | undefined { + const manifest = installedManifestsOfEngine(engine)(packageId); + return kernelPackageScopeOf(manifest === undefined ? undefined : { manifest }); +} + /** * The calls in flight, per registry and per pair id — see the module header, * "Idempotent, and one pair under concurrency". Keyed weakly by the registry, @@ -252,7 +286,7 @@ export async function ensureCustomizationPackage( } const baseId = declared.data.basePackageId; - const pair = pairOf(baseId); + const pair = pairOf(baseId, installedManifestsOfEngine(this.engine)); if (!pair.ok) { throw refusal(400, pair.reason === 'base_is_customization_package' ? `Package '${baseId}' is itself a paired customization package, and a pair is never paired again: ` @@ -304,12 +338,34 @@ export function customizationPairEnsureRefusal( registry: PairRegistry, baseId: string, pairId: string, + options: { + /** + * `false` asks every other judgement of the door with the kernel + * verdict left out: the overlay-write closes read it so that a + * kernel item stays closed exactly where it was closed before the + * verdict (`overlayPairFixOf`). The door itself always judges it. + */ + readonly judgeKernel?: boolean; + } = {}, ): Error | undefined { const base = registry.getPackage(baseId); if (!base) { return refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed ` + 'base.'); } + // [ruling record 6691217245] A kernel package has no pair in this + // version, ensured or created — the door's verdict, before anything else + // about the base is judged. It refuses the ENSURE only: a pair an earlier + // version of this door created for a kernel package stays installed, and + // the overrides inside it stay served and writable (the override gate and + // the served-override read never ask this door). + const kernelScope = options.judgeKernel === false ? undefined : kernelPackageScopeOf(base); + if (kernelScope !== undefined) { + return refusal(409, `Package '${baseId}' is a kernel package (scope '${kernelScope}'): kernel objects are not ` + + `customizable in this version, so '${baseId}' has no paired customization package and '${pairId}' is ` + + 'not ensured. A pair an earlier version created stays installed, and the overrides in it stay served ' + + '(ADR-0146 D3).'); + } // ADR-0070 D2's one predicate. A writable base is the customer's own and // takes their changes directly; the pair exists for a base the customer // cannot author into. diff --git a/packages/metadata-protocol/src/protocol-diff-delete-load.ts b/packages/metadata-protocol/src/protocol-diff-delete-load.ts index 9a30d5d88df..9df68e01d38 100644 --- a/packages/metadata-protocol/src/protocol-diff-delete-load.ts +++ b/packages/metadata-protocol/src/protocol-diff-delete-load.ts @@ -38,6 +38,7 @@ import { import { isMissingTableError } from '@objectstack/metadata/errors'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import type { ServedOverrideRow } from './served-override.js'; import { REFERENCE_SITES } from './reference-sites.js'; import { computeMetadataDiagnostics } from './metadata-diagnostics.js'; @@ -426,6 +427,7 @@ export class ProtocolDiffDeleteLoadFacet { // is ({@link isPairRowRemoval}). const pairRowRemoval = isPairRowRemoval( request.type, host.packagedArtifactOwner({ type: request.type, name: request.name }), request.packageId, + installedManifestsOfEngine(host.engine), ); // Two-tier authorization for delete (mirrors saveMetaItem). // • Artifact-backed item → delete becomes a tombstone overlay, @@ -1164,6 +1166,7 @@ export class ProtocolDiffDeleteLoadFacet { && !isServedOverrideRowOf( record as ServedOverrideRow, host.packagedArtifactOwner({ type: normalizedType, name: String(record.name) }), + installedManifestsOfEngine(host.engine), ) ) { sealedOverlays.push({ type: normalizedType, name: String(record.name) }); diff --git a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts index 77a0451a7f7..4ad531afa4a 100644 --- a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts +++ b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts @@ -47,6 +47,7 @@ import { getMetadataTypeSchema, } from '@objectstack/spec/kernel'; import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { storedBodyForCarryForward } from './protocol-credential-carry-forward.js'; import type { Equal, MetaTypesListing } from './protocol-meta-types-listing.js'; import { @@ -405,9 +406,9 @@ export class ProtocolDiscoveryAndBasesFacet { * So the bundle is exactly the pair's share of the catalog the deployment * serves, with its package kept. */ - static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise { + static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise { if (typeof basePackageId !== 'string' || basePackageId === '') return undefined; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installedManifestsOfEngine(host.engine)); if (!pair.ok) return undefined; const layer = await readAuthoredTranslationLayer({ find: (table: string, opts: { where?: Record; [key: string]: unknown } = {}) => diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index 4f8f77ab777..96d30a0cef4 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -44,13 +44,14 @@ * not move. */ -import { pairOf } from '@objectstack/metadata-core'; +import { pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import type { StoredOverlayRow } from './item-lock.js'; import { overlayMemberWhere } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; import { rowMatchesOverlayWhere, servedOverlayRowCandidates } from './protocol.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -96,7 +97,7 @@ export async function findEnvironmentRow(this: Pick, + this: Pick, args: { type: string; name: string; @@ -117,7 +118,8 @@ export async function findServedOverlayRow( // collision would serve package B's customization for a package A // read); with no package context (legacy/runtime reader), any row. const other = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; - for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId, servedOverride: args.servedOverride })) { + const installed = installedManifestsOfEngine(this.engine); + for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId, servedOverride: args.servedOverride, installed })) { const type = candidate.spelling === 'canonical' ? args.type : other; if (type === undefined) continue; const where: Record = { @@ -142,7 +144,7 @@ export async function findServedOverlayRow( // package ({@link isServedOverrideRowOf}, on the row's own binding and // body): an undeclared row of the pair is the pair's own item. if (candidate.overrideOf !== undefined) { - if (isServedOverrideRowOf(row, candidate.overrideOf)) return { row, overrideOf: candidate.overrideOf }; + if (isServedOverrideRowOf(row, candidate.overrideOf, installed)) return { row, overrideOf: candidate.overrideOf }; continue; } return { row }; @@ -290,9 +292,14 @@ export function soleShippingPackage(shipped: readonly unknown[]): string | undef * PR #28777). A declared override is served as the base's item by the * served-row resolution and is never asked about here. */ -export function baseAddressServesMember(base: string, bound: string | undefined, baseShipsMember: boolean): boolean { +export function baseAddressServesMember( + base: string, + bound: string | undefined, + baseShipsMember: boolean, + installed: InstalledManifestLookup, +): boolean { if (bound === undefined || bound === base) return true; if (baseShipsMember) return false; - const pair = pairOf(base); + const pair = pairOf(base, installed); return pair.ok && pair.value === bound; } diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts index 622ca97d212..ba35543e7a1 100644 --- a/packages/metadata-protocol/src/protocol-lock-state.ts +++ b/packages/metadata-protocol/src/protocol-lock-state.ts @@ -39,7 +39,8 @@ * keeps calling the class method, the REST and runtime published doors among * them. * - * The bodies read no field, so the host carries no getter. + * One body reads a field, `engine` (the registry the customization pair + * lookup reads, `installedManifestsOfEngine`), handed over as a getter. * * The one module-level declaration of `protocol.ts` the bodies read, * `canonicalMetaType`, stays there, where code that did not move reads it too, @@ -55,6 +56,7 @@ import { canonicalMetaType } from './protocol.js'; import type { ItemLock } from './item-lock.js'; import { isOriginGatedType } from './packaged-base-regime.js'; import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { servesSealedOverrideRow } from './protocol-package-bindings.js'; import { isCodeArtifactBody } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -74,6 +76,7 @@ import type { resolveLockState } from '@objectstack/spec/kernel'; * here too, and naming a member the class does not have is a compile error. */ export interface LockStateHost { + readonly engine: ObjectStackProtocolImplementation['engine']; declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow']; isDeclaredCodeDatasource: ObjectStackProtocolImplementation['isDeclaredCodeDatasource']; isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; @@ -143,14 +146,15 @@ export function isStoredFlowEntryOfShippedName( /** The body of `ObjectStackProtocolImplementation#declinesStoredRow`; the method's docblock there is its contract. */ export function declinesStoredRow( - this: Pick, + this: Pick, type: string, name: unknown, row?: ServedOverrideRow): boolean { + const installed = installedManifestsOfEngine(this.engine); if (this.isShippedFlowName(type, name)) { // [#28412, ADR-0146 D9 item 1] …except the one row that IS the // packaged flow now: the shipping package's declared override, judged // on the row's own `package_id` and body by the one served-override // predicate. Every other stored row of the name stays declined. - return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string })); + return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string }), installed); } if (typeof name !== 'string' || name === '') return false; if (this.isDeclaredCodeDatasource(type, name)) return true; @@ -165,7 +169,7 @@ export function declinesStoredRow( // row, so the layered read's `effective` and a by-name read answered by // the metadata service see it as the registry's readers do. return !((servesSealedOverrideRow(type) || canonicalMetaType(type) === 'action') - && isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name }))); + && isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name }), installed)); } /** The body of `ObjectStackProtocolImplementation#isUnservedSealedOverlay`; the method's docblock there is its contract. */ diff --git a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts index 5c24659a7e6..3076466658e 100644 --- a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts @@ -58,6 +58,7 @@ import { rowMatchesOverlayWhere } from './protocol-overlay-merge.js'; import { boundPackageOf } from './protocol-package-bindings.js'; import type { RowlessExpandedView } from './protocol-stored-overlay-types.js'; import { type ServedOverrideRow, isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { packagelessAddressAmbiguityRefusal } from './sys-metadata-repository.js'; /** The body of `ObjectStackProtocolImplementation#packagelessLayeredReadRefusal`; the method's docblock there is its contract. */ @@ -399,18 +400,19 @@ export async function getMetaItemLayered(host: Pick boundPackageOf(row.package_id); + const installed = installedManifestsOfEngine(host.engine); const orphan = rows .filter((row) => rowMatchesOverlayWhere(row, memberWhere)) .filter((row) => { const owner = boundTo(row); if (owner === undefined || owner === base) return false; - if (isServedOverrideRowOf(row, base)) return false; - const pair = pairOf(base); + if (isServedOverrideRowOf(row, base, installed)) return false; + const pair = pairOf(base, installed); if (pair.ok && pair.value === owner) return true; return host.resolveWritePackageScope(owner)?.dependencies.includes(base) === true; }) .sort((a, b) => { - const pair = pairOf(base); + const pair = pairOf(base, installed); const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1); return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b))); })[0]; diff --git a/packages/metadata-protocol/src/protocol-meta-list-reads.ts b/packages/metadata-protocol/src/protocol-meta-list-reads.ts index 76155c26f82..22974ca3404 100644 --- a/packages/metadata-protocol/src/protocol-meta-list-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-list-reads.ts @@ -48,6 +48,7 @@ import { import { storedEntryRow } from './protocol-stored-overlay-types.js'; import { viewIdentityPatch } from './protocol-view-normalization.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The body of `ObjectStackProtocolImplementation#getMetaItems`; the method's docblock there is its contract. */ export async function getMetaItems(host: Pick, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) { @@ -82,6 +83,9 @@ export async function readFlattenedMetaItems(host: Pick row?.package_id === pairOfScope.value) : []; @@ -267,7 +271,7 @@ export async function readFlattenedMetaItems(host: Pick typeof row?.name === 'string' && isServedOverrideRowOf(row, pkg)) + .filter((row) => typeof row?.name === 'string' && isServedOverrideRowOf(row, pkg, installed)) .map((row) => row.name as string); if (overridden.length > 0) names = new Set([...names, ...overridden]); } @@ -527,7 +531,7 @@ export async function readFlattenedMetaItems(host: Pick record?.package_id === pairOfDraftScope.value) : []; @@ -562,7 +566,7 @@ export async function readFlattenedMetaItems(host: Pick { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, unseated, { servedOverride }); + }, unseated, { servedOverride, installed }); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -671,7 +675,7 @@ export async function readFlattenedMetaItems(host: Pick> = base === null ? [{ packageId: null }] @@ -126,6 +111,39 @@ export function servedOverlayRowCandidates(address: { places.map((place) => ({ spelling, ...place }))); } +/** + * The address {@link servedOverlayRowCandidates} orders the rows of. A read + * that may serve a declared override names the registry it serves + * (`installed`), because the address's pair is the pair of the App that + * carries its package (`pairOf`); a read that never serves one names neither, + * so `servedOverride` without `installed` does not compile. + */ +export type ServedOverlayAddress = + | { + /** + * [#23715] `null` addresses the package-unbound row alone: the strict + * draft read naming no package, at the draft its `version` names + * ({@link ObjectStackProtocolImplementation.strictDraftReadAddress}). + */ + readonly packageId: string | null | undefined; + readonly servedOverride?: undefined; + readonly installed?: undefined; + } + | { + readonly packageId: string | null | undefined; + /** + * [#28409, ADR-0146 D8 item 2] Whether the address's declared override + * is a candidate: the reads that SERVE a package's name (the active + * by-name read and its draft preview, the layered read, the list's merges) + * pass `true`. A read that answers a write's address (the strict draft + * read, the view container expansion's package order) passes nothing: a + * save naming the base writes the base's own partition, never its pair's. + */ + readonly servedOverride: boolean | undefined; + /** The installed manifests of the registry the read serves, read for the address's pair. */ + readonly installed: InstalledManifestLookup; + }; + /** * [#24371] Whether a stored row read by * {@link ObjectStackProtocolImplementation.readItemActiveRows} is one the @@ -261,16 +279,24 @@ export function mergePackageAwareOverlay( records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, unseated?: WeakSet, - options: { - /** - * [#28409, ADR-0146 D8 item 2] Serve each package's declared override - * over its slot ({@link servedOverlayRowCandidates}' `servedOverride`): - * the effective list's merges pass `true`; the per-package layer - * ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) - * and the MetadataService merge pass nothing. - */ - readonly servedOverride?: boolean; - } = {}, + options: + | { readonly servedOverride?: undefined; readonly installed?: undefined } + | { + /** + * [#28409, ADR-0146 D8 item 2] Serve each package's declared override + * over its slot ({@link servedOverlayRowCandidates}' `servedOverride`): + * the effective list's merges pass `true`; the per-package layer + * ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) + * and the MetadataService merge pass nothing. + */ + readonly servedOverride: boolean | undefined; + /** + * The installed manifests of the registry the list serves: the + * declared override of a package's slot is its App's pair's + * ({@link servedOverlayRowCandidates}). + */ + readonly installed: InstalledManifestLookup; + } = {}, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. // `stored` marks a contribution that is a stored row the caller read; @@ -298,7 +324,9 @@ export function mergePackageAwareOverlay( list: readonly Contribution[], real: string | undefined, ): { item: any; overrideOf?: string } | undefined => { - for (const candidate of servedOverlayRowCandidates({ packageId: real, servedOverride: options.servedOverride })) { + for (const candidate of servedOverlayRowCandidates(options.installed !== undefined + ? { packageId: real, servedOverride: options.servedOverride, installed: options.installed } + : { packageId: real })) { const hit = list.find((c) => c.stored !== undefined && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId) @@ -308,6 +336,7 @@ export function mergePackageAwareOverlay( // and its body, never on a stamp the body carries. && (candidate.overrideOf === undefined || isServedOverrideRowOf( { type: c.stored.type, package_id: c.pkg, metadata: c.item }, candidate.overrideOf, + options.installed!, ))); if (hit) return candidate.overrideOf === undefined ? { item: hit.item } : { item: hit.item, overrideOf: candidate.overrideOf }; } diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts index e28599b271e..721a883e955 100644 --- a/packages/metadata-protocol/src/protocol-package-bindings.ts +++ b/packages/metadata-protocol/src/protocol-package-bindings.ts @@ -15,6 +15,7 @@ import { } from './item-lock.js'; import { itemDiscriminator, + type InstalledManifestLookup, } from '@objectstack/metadata-core'; import { isServedOverrideRowOf } from './served-override.js'; import { canonicalMetaType } from './protocol-meta-type-schema.js'; @@ -269,6 +270,7 @@ function perPackageLayerEnvelope( itemPackageId: string | undefined, artifact: unknown, rowsOfName: readonly any[], + installed: InstalledManifestLookup, ): unknown { if (artifact === undefined || artifact === null || typeof artifact !== 'object') return artifact; const own = boundPackageOf(itemPackageId); @@ -277,7 +279,7 @@ function perPackageLayerEnvelope( if (lender === own) return artifact; const member = bundleMemberOf(type, item); const declared = rowsOfName.some((row) => { - if (row?.package_id !== own || !isServedOverrideRowOf(row, lender)) return false; + if (row?.package_id !== own || !isServedOverrideRowOf(row, lender, installed)) return false; try { return bundleMemberOf(type, storedRowDocument(row)) === member; } catch { diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index e1d778dff0c..2e24bfb8453 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -45,7 +45,7 @@ import { } from './packaged-base-regime.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js'; -import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; +import { customizationPairEnsureRefusal, kernelPackageScope, type PairRegistry } from './protocol-customization-package.js'; import { resolveOverlaySchema } from './protocol-meta-type-schema.js'; import { resolvePackageClosure } from '@objectstack/lint/runtime'; import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; @@ -58,7 +58,8 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec'; -import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf } from '@objectstack/metadata-core'; +import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -68,6 +69,7 @@ import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf } from '@o * here too, and naming a member the class does not have is a compile error. */ export interface PackagedBaseRefusalHost { + readonly engine: ObjectStackProtocolImplementation['engine']; isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; @@ -78,7 +80,7 @@ export interface PackagedBaseRefusalHost { /** The body of `ObjectStackProtocolImplementation#refusePackagedBaseOverride`; the method's docblock there is its contract. */ export function refusePackagedBaseOverride( - this: Pick, + this: Pick, request: { type: string; name: string; packageId?: string | null }, ): void { if (this.isSealedManagedItem(request.type, request.name)) { @@ -163,11 +165,12 @@ export function refusePackagedBaseOverride( // [#28412] A Regime C type the declared override opens (`flow`) names // the declaration in place of its clone path, keeping its switch. const owner = this.packagedArtifactOwner(request); - const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId); + const installed = installedManifestsOfEngine(this.engine); + const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId, installed); const err = new Error( (declare === undefined ? undefined : regimeCDeclaredSaveSentence(request.type, request.name, declare)) ?? managedItemSealedSentence(request.type, request.name, 'save') - + sealedDeclareOrRenameTail(request.type, owner, request.packageId), + + sealedDeclareOrRenameTail(request.type, owner, request.packageId, installed), ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; @@ -189,10 +192,15 @@ export function refusePackagedBaseOverride( * when the shipping package has no pair id. [#28412] `flow` is open: its * sealed sentence names the declaration too, and so, [#30065], does `action`'s. */ -function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerPackageId: unknown): string { +function sealedDeclareOrRenameTail( + type: string, + basePackageId: unknown, + writerPackageId: unknown, + installed: InstalledManifestLookup, +): string { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return ''; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); if (!pair.ok) return ''; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; if (writer !== undefined && writer !== pair.value) { @@ -209,10 +217,15 @@ function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerP * may declare from the pair or rename; any other named package may only * rename. `undefined` exactly where the tail is empty. */ -function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, writerPackageId: unknown): string | undefined { +function sealedDeclareOrRenameClause( + type: string, + basePackageId: unknown, + writerPackageId: unknown, + installed: InstalledManifestLookup, +): string | undefined { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return undefined; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); if (!pair.ok) return undefined; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; if (writer !== undefined && writer !== pair.value) { @@ -250,9 +263,12 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac */ export function packagelessOverlayWriteRefusal( request: { type: string; name: string }, - closed: { base: string; pair: string }, + closed: OverlayWriteClose, operation: PackagelessWriteOperation, ): Error { + if (closed.kernelScope !== undefined) { + return kernelOverlayWriteRefusal(request, closed.base, closed.kernelScope, 'packageless', operation); + } const { item, shipped, pair, declared, writeInto, ceremony } = closedOverlayWriteClauses(request, closed); const rule = `A write naming no package is closed where ${ceremony} can move its row ` + `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`; @@ -297,6 +313,37 @@ function closedOverlayWriteClauses( return { item, shipped, pair, declared, writeInto, ceremony, boundRowServed }; } +/** + * [#29594, ruling record 6691217245] Both closes' refusal of a write onto an + * item a kernel package ships ({@link OverlayWriteClose.kernelScope}). Kernel + * objects are not customizable in this version and the kernel has no pair, so + * the sentence names no pair and no fix: the write stays closed (ADR-0146 D11) + * where it was closed before the kernel verdict, under the same + * `403 NOT_OVERRIDABLE`, until a later version's kernel whitelist opens the + * item. A row stored before the close stays served; a row bound to the base + * is still removed by a delete naming it. + */ +function kernelOverlayWriteRefusal( + request: { type: string; name: string }, + base: string, + kernelScope: string, + address: 'packageless' | 'named_base', + operation: PackagelessWriteOperation, +): Error { + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const item = `'${type}/${request.name}' ships in kernel package '${base}' (scope '${kernelScope}')`; + const write = address === 'packageless' + ? (operation === 'delete' || operation === 'rollback' + ? `a ${operation} of its package-less row` + : 'a write naming no package') + : (operation === 'rollback' ? `a rollback of its row bound to '${base}'` : `a write naming '${base}'`); + const stored = address === 'packageless' + ? 'A package-less row stored before the close stays served.' + : `A row bound to '${base}' stored before the close stays served until a delete naming '${base}' removes it.`; + return closedOverlayWriteError(`${item}: kernel objects are not customizable in this version, so ${write} is ` + + `refused (ADR-0146 D3, D11). ${stored}`); +} + /** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */ function closedOverlayWriteError(message: string): Error { const err = new Error(message); @@ -344,9 +391,12 @@ export type NamedBaseWriteOperation = Exclude registry.getPackage(id) as { manifest?: unknown } | undefined); const dependencies = [base, ...(baseClosure?.dependencies ?? []).filter((id) => id !== pair)]; - return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies } }; + return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies }, ...kernel }; } /** @@ -576,7 +653,7 @@ export interface NamedBaseWriteSubject { export function namedBaseCloseSubject( this: NamedBaseCloseHost, request: NamedBaseWriteSubject, -): { base: string; pair: string } | null { +): OverlayWriteClose | null { const base = typeof request.packageId === 'string' && request.packageId.length > 0 ? request.packageId : undefined; if (base === undefined) return null; // the package-less write: #28576's close // A body carrying `overrides` keeps its answer: the D2 gate refuses a @@ -594,7 +671,7 @@ export function namedBaseCloseSubject( ? overlayMemberOfBody(type, written) : overlayMemberValue(type, request.member); if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body: written, member })) return null; - return { base, pair: fix.pair }; + return fix.kernelScope === undefined ? { base, pair: fix.pair } : { base, pair: fix.pair, kernelScope: fix.kernelScope }; } // ───────────────────────────────────────────────────────────────────────── @@ -674,12 +751,17 @@ function isOpenOverrideType(singular: string): boolean { * verdict. [#30065] `action` is open, so a pair's removal of its declared * action override is admitted and the base's action is served again. */ -export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean { +export function isPairRowRemoval( + type: string, + basePackageId: unknown, + packageId: unknown, + installed: InstalledManifestLookup, +): boolean { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular)) return false; if (typeof basePackageId !== 'string') return false; if (typeof packageId !== 'string' || packageId.length === 0) return false; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); return pair.ok && pair.value === packageId; } @@ -825,6 +907,13 @@ export interface OverrideWriteClosure { * answering none of that name, the refusal reads as before. */ readonly baseItemsOf?: (basePackageId: string) => readonly unknown[]; + /** + * The installed manifests the door reads (`installedManifestsOf` over its + * registry, or over the manifests an offline door holds): who may declare + * an override of a base is the pair of the App that carries the base + * (`pairOf`, `isPairOf` in `@objectstack/metadata-core`). + */ + readonly installed: InstalledManifestLookup; } /** [#28834] The item door an `object_extension` is saved through (#28574), as the refusals name it. */ @@ -1071,8 +1160,8 @@ export function judgeOverrideWrite( + 'override is refused (ADR-0146 D9). Remove `overrides` and rename the item.'); } const base = declaration.base; - const pair = base === undefined ? undefined : pairOf(base); - if (base === undefined || pair === undefined || !pair.ok || !isPairOf(packageId, base)) { + const pair = base === undefined ? undefined : pairOf(base, closure.installed); + if (base === undefined || pair === undefined || !pair.ok || !isPairOf(packageId, base, closure.installed)) { const who = packageId === undefined ? 'a row bound to no package' : `package '${packageId}'`; const only = pair?.ok ? `only '${pair.value}' may declare an override of '${base}'` @@ -1211,7 +1300,7 @@ export function overrideWriteRefusalError( * write passes none. */ export function overrideWriteVerdict( - this: Pick, + this: Pick, request: OverrideWriteSubject, writtenScope?: WrittenPackageScope, ): OverrideWriteVerdict { @@ -1262,7 +1351,9 @@ export function overrideWriteVerdict( const baseItemsOf = (basePackageId: string): unknown[] => this.shippedArtifactsOf(request.type, request.name, undefined, member) .filter((a) => overrideWriteMemberOf(request.type, a) === member && (a as { _packageId?: unknown } | null | undefined)?._packageId === basePackageId); - return judgeOverrideWrite(request, { dependencies, shippers, baseItemOf, baseItemsOf }); + return judgeOverrideWrite(request, { + dependencies, shippers, baseItemOf, baseItemsOf, installed: installedManifestsOfEngine(this.engine), + }); } diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 13a692fd2ae..f6457a8107f 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -136,7 +136,7 @@ import { OVERRIDABLE_METADATA_TYPES, customizationBasePackageId, } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; @@ -144,12 +144,14 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { overlayPairFixAdmits, overlayPairFixOf, + type OverlayWriteClose, overrideDeclarationRefusedByShape, overrideWriteMemberOf, type OverrideWriteVerdictAsk, } from './protocol-packaged-base-refusal.js'; import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The class members this module reads, typed as the class declares them. */ export interface PackagelessOverlayMigrationHost { @@ -377,10 +379,11 @@ export function classifyPackagelessRow( shippers: readonly string[], body: Record | undefined, shadowOf: (pair: string) => unknown, + installed: InstalledManifestLookup, ): Pick { if (shippers.length > 1) return { disposition: 'multi_shipped' }; const base = shippers[0]!; - const pairId = pairOf(base); + const pairId = pairOf(base, installed); if (!pairId.ok) return { base, disposition: 'no_pair' }; const pair = pairId.value; if (body !== undefined && overrideDeclarationRefusedByShape(type, body)) return { base, pair, disposition: 'cannot_declare' }; @@ -391,7 +394,7 @@ export function classifyPackagelessRow( } const shadow = shadowOf(pair); if (shadow !== undefined) { - return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) }; + return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base, installed) }; } return { base, pair, disposition: 'movable' }; } @@ -405,7 +408,7 @@ export function classifyPackagelessRow( * classifies it; an unprovisioned table answers an empty census. */ export async function listPackagelessOverlays( - this: Pick, + this: Pick, ): Promise { const rows = await overlayRows.call(this); // What every pair holds, per item identity and state. @@ -456,7 +459,7 @@ export async function listPackagelessOverlays( const body = bodyOf(row); // A row bound to the base names its base: the binding is its one shipper. const classified = classifyPackagelessRow(type, binding !== undefined ? [binding] : shippers, body, - (pair) => shadowOf(pair, type, name, member, state)); + (pair) => shadowOf(pair, type, name, member, state), installedManifestsOfEngine(this.engine)); const addedMember = classified.disposition === 'movable' && classified.base !== undefined ? addedMemberOf(this, type, name, classified.base, body) : undefined; @@ -473,7 +476,7 @@ export async function listPackagelessOverlays( for (const row of await environmentRowsOfTypes.call(this, OVERRIDABLE_TYPE_SPELLINGS)) { if (isUnbound(row)) continue; const base = customizationBasePackageId(row.package_id); - if (base === null || !isServedOverrideRowOf(row, base)) continue; + if (base === null || !isServedOverrideRowOf(row, base, installedManifestsOfEngine(this.engine))) continue; const basedOn = (bodyOf(row) as { overrides?: { basedOn?: unknown } } | undefined)?.overrides?.basedOn; if (typeof basedOn === 'string' && basedOn !== '') continue; const member = memberOf(row); @@ -611,7 +614,7 @@ export async function migratePackagelessOverlays( // Step 3: read back — the pair's row must be the served override, // or, for an added member, that member's own row declaring nothing. const written = await storedPairRow(this, row, row.pair); - if (addedMember === undefined ? !isServedOverrideRowOf(written, row.base) : !isOwnMemberRow(written)) { + if (addedMember === undefined ? !isServedOverrideRowOf(written, row.base, installedManifestsOfEngine(this.engine)) : !isOwnMemberRow(written)) { const kept = bound ? `the row bound to '${row.base}' is kept` : 'the unbound row is kept'; fail(addedMember === undefined ? `The row saved into '${row.pair}' does not read back as the declared override of '${row.base}'; ${kept}.` @@ -674,7 +677,7 @@ export async function migratePackagelessOverlays( } as never); const written = await storedPairRow(this, decl, decl.pair); const recorded = (bodyOf(written) as { overrides?: { basedOn?: unknown } } | undefined)?.overrides?.basedOn; - if (!isServedOverrideRowOf(written, decl.base) || recorded !== basedOn) { + if (!isServedOverrideRowOf(written, decl.base, installedManifestsOfEngine(this.engine)) || recorded !== basedOn) { fail(`The row of '${decl.pair}' does not read back with basedOn '${basedOn}'.`); continue; } @@ -754,7 +757,11 @@ export interface PackagelessWriteSubject { * locale the base does not ship is refused there (#28777) and stays open: * the ceremony moves such a row as the pair's own member, declaring * nothing (#28904), and the close reaches only the declared-override - * moves, so its package-less write is the one it had before the close. + * moves, so its package-less write is the one it had before the close; + * - [#29594] an item a kernel package ships stays closed where it was + * before the kernel verdict, though the ensure door creates no pair for + * it: the subject carries the base's `kernelScope`, and the refusal says + * kernel objects are not customizable in this version, naming no pair. * * Registry reads first; the one store read (the item's rows) only for a name * the registry has not already left open. @@ -762,7 +769,7 @@ export interface PackagelessWriteSubject { export async function packagelessCloseSubject( this: PackagelessCloseHost, request: PackagelessWriteSubject, -): Promise<{ base: string; pair: string } | null> { +): Promise { if (typeof request.packageId === 'string' && request.packageId.length > 0) return null; const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; const shippers = this.overlayBaseShippers(request.type, request.name); @@ -796,10 +803,10 @@ export async function packagelessCloseSubject( const classified = classifyPackagelessRow(type, shippers, body, (p) => (stored === undefined ? undefined : ofItem.find((row) => row.package_id === p && stateOf(row) === request.state) - ?? ofItem.find((row) => row.package_id === p))); + ?? ofItem.find((row) => row.package_id === p)), installedManifestsOfEngine(this.engine)); if (classified.disposition !== 'movable') return null; // Step 2's gate: the declared body, into the pair. if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body, member })) return null; - return { base, pair: fix.pair }; + return fix.kernelScope === undefined ? { base, pair: fix.pair } : { base, pair: fix.pair, kernelScope: fix.kernelScope }; } diff --git a/packages/metadata-protocol/src/protocol-registry-hydration.ts b/packages/metadata-protocol/src/protocol-registry-hydration.ts index 922d07517f9..0a3f8f4e100 100644 --- a/packages/metadata-protocol/src/protocol-registry-hydration.ts +++ b/packages/metadata-protocol/src/protocol-registry-hydration.ts @@ -31,7 +31,7 @@ import { overlayMemberWhere } from './migrations/overlay-index.js'; import { storedRowDocument } from './item-lock.js'; -import { itemDiscriminator } from '@objectstack/metadata-core'; +import { installedManifestsOf, itemDiscriminator } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { bindStoredRow } from './stored-row-binding.js'; @@ -524,7 +524,7 @@ export class ProtocolRegistryHydrationFacet { ? registry.getItem(type, String((data as any).name)) : undefined; const owner = host.packagedArtifactOwner({ type, name: String((data as any).name) }); - if (!incomingTakesStoredFlowSlot(held, registered, owner)) return true; + if (!incomingTakesStoredFlowSlot(held, registered, owner, installedManifestsOf(registry))) return true; } registry.registerItem(type, registered, 'name' as any); await host.hydrateExpandedViewItems(type, data, options); diff --git a/packages/metadata-protocol/src/protocol-served-overrides.ts b/packages/metadata-protocol/src/protocol-served-overrides.ts index 50fb59817f2..0d548f4c8dd 100644 --- a/packages/metadata-protocol/src/protocol-served-overrides.ts +++ b/packages/metadata-protocol/src/protocol-served-overrides.ts @@ -56,9 +56,11 @@ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The class members {@link listServedOverrides} reads, typed as the class declares them. */ export interface ServedOverridesHost { + readonly engine: ObjectStackProtocolImplementation['engine']; readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows']; readonly declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow']; readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned']; @@ -91,11 +93,12 @@ export interface ServedOverrideEntry { * An unprovisioned table answers `[]`. */ export async function listServedOverrides( - this: Pick, + this: Pick, request: { packageId: string }, ): Promise { const base = request.packageId; - const pair = pairOf(base); + const installed = installedManifestsOfEngine(this.engine); + const pair = pairOf(base, installed); if (!pair.ok) return []; let rows: any[] = []; try { @@ -112,7 +115,7 @@ export async function listServedOverrides( const entries = new Map(); for (const row of rows ?? []) { if (typeof row?.name !== 'string' || row.name === '') continue; - if (!isServedOverrideRowOf(row, base)) continue; + if (!isServedOverrideRowOf(row, base, installed)) continue; const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; if (this.declinesStoredRow(type, row.name, row)) continue; const member = typeof row.bundle_member === 'string' && row.bundle_member !== '' ? row.bundle_member : undefined; diff --git a/packages/metadata-protocol/src/protocol-write-gates.ts b/packages/metadata-protocol/src/protocol-write-gates.ts index 9244880c1e1..24b162223d6 100644 --- a/packages/metadata-protocol/src/protocol-write-gates.ts +++ b/packages/metadata-protocol/src/protocol-write-gates.ts @@ -38,6 +38,7 @@ import { envWritableMetadataTypes } from './sys-metadata-repository.js'; import { managedItemSealedSentence } from './packaged-base-regime.js'; import type { StoredOverlayRow } from './item-lock.js'; import { itemDiscriminator, pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { baseAddressServesMember, readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; @@ -637,10 +638,11 @@ export class ProtocolWriteGatesFacet { * `item` is. The one member-grain answer the env-wide list, the list * scoped to `base` and the by-name reads give. */ - static baseServesBundleMember(host: Pick, type: string, name: string, base: string, item: unknown, shipping: ShippingPackages): boolean { + static baseServesBundleMember(host: Pick, type: string, name: string, base: string, item: unknown, shipping: ShippingPackages): boolean { const member = itemDiscriminator(canonicalMetaType(type), item); const baseShipsMember = shippingPackageIds(host.shippedArtifactsOf(type, name, shipping, member)).has(base); - return baseAddressServesMember(base, boundPackageOf((item as { _packageId?: unknown } | null | undefined)?._packageId), baseShipsMember); + return baseAddressServesMember(base, boundPackageOf((item as { _packageId?: unknown } | null | undefined)?._packageId), baseShipsMember, + installedManifestsOfEngine(host.engine)); } /** @@ -649,11 +651,11 @@ export class ProtocolWriteGatesFacet { * pair can add on `base`'s address: `base` ships the name and not that * member. Else `undefined`, and the by-name read serves as before. */ - static pairOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): string | undefined { + static pairOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): string | undefined { const shipping = host.shippingPackagesOf(type); if (!host.shipsBundleName(type, name, base, shipping)) return undefined; if (shippingPackageIds(host.shippedArtifactsOf(type, name, shipping, member)).has(base)) return undefined; - const pair = pairOf(base); + const pair = pairOf(base, installedManifestsOfEngine(host.engine)); return pair.ok ? pair.value : undefined; } diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index 301a103cf86..55851d647e4 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -26,8 +26,10 @@ * provenance). `@objectstack/objectql` cannot be imported here: it depends on * this package. */ +// Pays the lint root's first load at collection, never inside a clocked case. +import './lint-root-preload.testkit.js'; import { afterEach, describe, expect, it } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, installedManifestsOf } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY, OVERRIDE_TYPE_CLASSIFICATION } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { judgeOverrideWrite } from './protocol-packaged-base-refusal.js'; @@ -999,7 +1001,7 @@ describe('[#30062] each excluded type\'s refusal names a remedy that works for i const refusalOf = (type: string): string => { const verdict = judgeOverrideWrite( { type, name: 'their_item', packageId: PAIR_ID, item: { name: 'their_item', overrides: { package: BASE_ID, basedOn: '1.0.0' } } }, - { dependencies: [BASE_ID], shippers: [BASE_ID] }, + { dependencies: [BASE_ID], shippers: [BASE_ID], installed: installedManifestsOf(undefined) }, ); expect(verdict, type).toMatchObject({ verdict: 'refuse', rule: 'declared_type_not_overridable' }); return (verdict as { message: string }).message; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 38475af4ac5..3d39d62d1b0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3824,7 +3824,9 @@ export class ObjectStackProtocolImplementation implements * table answers `[]`. */ async listServedOverrides(request: { packageId: string }): Promise { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), declinesStoredRow: (...a) => this.declinesStoredRow(...a), rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), @@ -3849,7 +3851,9 @@ export class ObjectStackProtocolImplementation implements * table answers an empty census. */ async listPackagelessOverlays(): Promise { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, overlayTypes: ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES, findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), @@ -4192,7 +4196,9 @@ export class ObjectStackProtocolImplementation implements servedOverride?: boolean; }, activeRows?: readonly StoredOverlayRow[]): Promise<{ row: any; overrideOf?: string } | undefined> { // [#25467 cut 35] The body moved to `protocol-environment-rows.ts` (see `EnvironmentRowsHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, findEnvironmentRow: (...a) => this.findEnvironmentRow(...a), }; return findServedOverlayRow.call(host, args, activeRows); @@ -6925,7 +6931,9 @@ export class ObjectStackProtocolImplementation implements */ declinesStoredRow(type: string, name: unknown, row?: ServedOverrideRow): boolean { // [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isDeclaredCodeDatasource: (...a) => this.isDeclaredCodeDatasource(...a), isShippedFlowName: (...a) => this.isShippedFlowName(...a), isUnservedSealedOverlay: (...a) => this.isUnservedSealedOverlay(...a), @@ -7204,7 +7212,9 @@ export class ObjectStackProtocolImplementation implements request: { type: string; name: string; packageId?: string | null }, ): void { // [#25467 cut 13] The body moved to `protocol-packaged-base-refusal.ts` (see `PackagedBaseRefusalHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isSealedManagedItem: (...a) => this.isSealedManagedItem(...a), isWritablePackage: (...a) => this.isWritablePackage(...a), packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), @@ -7248,7 +7258,9 @@ export class ObjectStackProtocolImplementation implements * with the closure a pair not yet ensured will have (`writtenScope`). */ private overrideWriteVerdict(request: OverrideWriteSubject, writtenScope?: WrittenPackageScope): OverrideWriteVerdict { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isWritablePackage: (...a) => this.isWritablePackage(...a), resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a), shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a), diff --git a/packages/metadata-protocol/src/served-override.test.ts b/packages/metadata-protocol/src/served-override.test.ts index e0c7b8f2350..fb301cc6354 100644 --- a/packages/metadata-protocol/src/served-override.test.ts +++ b/packages/metadata-protocol/src/served-override.test.ts @@ -10,6 +10,10 @@ */ import { describe, expect, it } from 'vitest'; import { isServedOverrideRowOf, servedOverrideBaseOf } from './served-override.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const BASE = 'com.acme.crm'; const PAIR = 'com.acme.crm.custom'; @@ -29,56 +33,56 @@ const row = (over: Record = {}) => ({ describe('[#28409] isServedOverrideRowOf', () => { it('a row of the pair, of an overridable type, declaring the base: the served override', () => { - expect(isServedOverrideRowOf(row(), BASE)).toBe(true); + expect(isServedOverrideRowOf(row(), BASE, NOTHING_INSTALLED)).toBe(true); // The body as stored, a JSON string, or already parsed. - expect(isServedOverrideRowOf(row({ metadata: body() }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body() }), BASE, NOTHING_INSTALLED)).toBe(true); // The type folds to its canonical singular, as the reads fold it. - expect(isServedOverrideRowOf(row({ type: 'views' }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ type: 'views' }), BASE, NOTHING_INSTALLED)).toBe(true); // `basedOn` is recorded and never judged (ADR-0146 D2). - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE } }) }), BASE)).toBe(true); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE, basedOn: '0.0.1' } }) }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE } }) }), BASE, NOTHING_INSTALLED)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE, basedOn: '0.0.1' } }) }), BASE, NOTHING_INSTALLED)).toBe(true); }); it('bound by the row\'s own `package_id`: another package\'s declaration is ignored (ruling A, #28390)', () => { - expect(isServedOverrideRowOf(row({ package_id: 'com.partner.ext' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: BASE }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: 'com.other.custom' }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: 'com.partner.ext' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: BASE }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: 'com.other.custom' }), BASE, NOTHING_INSTALLED)).toBe(false); // The same row is the pair of one base only. - expect(isServedOverrideRowOf(row(), 'com.acme')).toBe(false); + expect(isServedOverrideRowOf(row(), 'com.acme', NOTHING_INSTALLED)).toBe(false); }); it('never a body stamp: an unbound row whose body names the pair binds nothing', () => { const stamped = JSON.stringify(body({ _packageId: PAIR })); - expect(isServedOverrideRowOf(row({ package_id: null, metadata: stamped }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: undefined, metadata: stamped }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: '', metadata: stamped }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: null, metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: undefined, metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: '', metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('the declaration must name the base', () => { - expect(isServedOverrideRowOf(row({ metadata: JSON.stringify(body({ overrides: undefined })) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: 'com.other' } }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: PAIR } }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: BASE }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: [BASE] }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: null }) }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: JSON.stringify(body({ overrides: undefined })) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: 'com.other' } }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: PAIR } }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: BASE }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: [BASE] }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: null }) }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('a type a declaration never overrides: extended (object) or one name per deployment', () => { for (const type of ['object', 'objects', 'permission_set', 'position', 'capability']) { - expect(isServedOverrideRowOf(row({ type }), BASE), type).toBe(false); + expect(isServedOverrideRowOf(row({ type }), BASE, NOTHING_INSTALLED), type).toBe(false); } - expect(isServedOverrideRowOf(row({ type: undefined }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ type: undefined }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('total: false for any input it cannot read as a declaring row', () => { - expect(isServedOverrideRowOf(null, BASE)).toBe(false); - expect(isServedOverrideRowOf(undefined, BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: '{not json' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: '[]' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: null }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row(), '')).toBe(false); - expect(isServedOverrideRowOf(row(), undefined)).toBe(false); - expect(isServedOverrideRowOf(row(), 42)).toBe(false); + expect(isServedOverrideRowOf(null, BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(undefined, BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: '{not json' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: '[]' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: null }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), '', NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), undefined, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), 42, NOTHING_INSTALLED)).toBe(false); }); }); diff --git a/packages/metadata-protocol/src/served-override.ts b/packages/metadata-protocol/src/served-override.ts index e4904e8a57b..388a3969b4d 100644 --- a/packages/metadata-protocol/src/served-override.ts +++ b/packages/metadata-protocol/src/served-override.ts @@ -47,13 +47,14 @@ * before they ask this predicate (`declinesStoredRow`). Arming a flow override * is N-11's. * - * Pure, synchronous and total: no I/O, no registry read, and `false` for any - * input it cannot read as a declaring row. Whether the pair package is + * Pure, synchronous and total: no I/O, and `false` for any input it cannot + * read as a declaring row. Which App carries X is read through the caller's + * `installed` lookup (the registry it serves); whether the pair package is * INSTALLED is not its question. */ import { isOverridableMetadataType } from '@objectstack/spec/kernel'; -import { isPairOf } from '@objectstack/metadata-core'; +import { isPairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; @@ -84,10 +85,14 @@ export interface ServedOverrideRow { * | `package_id: null`, body `_packageId: 'hotcrm.custom'`, `overrides.package: 'hotcrm'` | `hotcrm` | `false` — a body stamp binds nothing | * | `object`, `package_id: 'hotcrm.custom'`, `overrides.package: 'hotcrm'` | `hotcrm` | `false` — extended, never replaced | */ -export function isServedOverrideRowOf(row: ServedOverrideRow | null | undefined, basePackageId: unknown): boolean { +export function isServedOverrideRowOf( + row: ServedOverrideRow | null | undefined, + basePackageId: unknown, + installed: InstalledManifestLookup, +): boolean { if (row === null || row === undefined || typeof row !== 'object') return false; if (typeof basePackageId !== 'string' || basePackageId === '') return false; - if (!isPairOf(row.package_id, basePackageId)) return false; + if (!isPairOf(row.package_id, basePackageId, installed)) return false; if (typeof row.type !== 'string' || !isOverridableMetadataType(PLURAL_TO_SINGULAR[row.type] ?? row.type)) { return false; } diff --git a/packages/metadata-protocol/src/stored-flow-slot.test.ts b/packages/metadata-protocol/src/stored-flow-slot.test.ts index 50c953ef23d..cb77e935b00 100644 --- a/packages/metadata-protocol/src/stored-flow-slot.test.ts +++ b/packages/metadata-protocol/src/stored-flow-slot.test.ts @@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest'; import { bindStoredRow } from './stored-row-binding.js'; import { incomingTakesStoredFlowSlot, shadowedStoredRowsOf } from './stored-flow-slot.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const BASE = 'com.acme.crm'; const PAIR = `${BASE}.custom`; @@ -26,58 +30,58 @@ describe('[#28717] the declared override keeps the bare slot of its shipped flow it('held override, incoming legacy row: the override keeps the slot and carries the legacy row', () => { const held = override(); const incoming = legacy(); - expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE, NOTHING_INSTALLED)).toBe(false); expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY']); }); it('held legacy row, incoming override: the override takes the slot and carries the legacy row', () => { const held = legacy(); const incoming = override(); - expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE, NOTHING_INSTALLED)).toBe(true); expect(labels(shadowedStoredRowsOf(incoming))).toEqual(['LEGACY']); }); it('the legacy row read again is carried once, as its latest body', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); const again = bindStoredRow(body('LEGACY 2'), null); - expect(incomingTakesStoredFlowSlot(held, again, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, again, BASE, NOTHING_INSTALLED)).toBe(false); expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY 2']); }); it('the override read again replaces itself and keeps what it shadowed, declared or not', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); const resaved = bindStoredRow(body('OVERRIDE, UNDECLARED'), PAIR); - expect(incomingTakesStoredFlowSlot(held, resaved, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, resaved, BASE, NOTHING_INSTALLED)).toBe(true); expect(labels(shadowedStoredRowsOf(resaved))).toEqual(['LEGACY']); }); it('the binding decides, never a body stamp: a package-less row stamping the pair is not the override', () => { const held = override(); const stamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); - expect(incomingTakesStoredFlowSlot(held, stamped, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, stamped, BASE, NOTHING_INSTALLED)).toBe(false); const heldStamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); - expect(incomingTakesStoredFlowSlot(heldStamped, legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(heldStamped, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); }); it('control: with no declared override among them, the last row read takes the slot and nothing is carried', () => { const held = legacy(); const undeclaredPair = bindStoredRow(body('PAIR, UNDECLARED'), PAIR); - expect(incomingTakesStoredFlowSlot(held, undeclaredPair, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, undeclaredPair, BASE, NOTHING_INSTALLED)).toBe(true); expect(shadowedStoredRowsOf(undeclaredPair)).toEqual([]); - expect(incomingTakesStoredFlowSlot(undeclaredPair, legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(undeclaredPair, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); }); it('control: an empty slot, a slot holding no stored row, or a name no package ships, settles nothing', () => { - expect(incomingTakesStoredFlowSlot(undefined, legacy(), BASE)).toBe(true); - expect(incomingTakesStoredFlowSlot(body('LOADER', { _packageId: BASE }), legacy(), BASE)).toBe(true); - expect(incomingTakesStoredFlowSlot(override(), legacy(), undefined)).toBe(true); + expect(incomingTakesStoredFlowSlot(undefined, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); + expect(incomingTakesStoredFlowSlot(body('LOADER', { _packageId: BASE }), legacy(), BASE, NOTHING_INSTALLED)).toBe(true); + expect(incomingTakesStoredFlowSlot(override(), legacy(), undefined, NOTHING_INSTALLED)).toBe(true); }); it('the carried rows never leave the process', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); expect(Object.keys(held)).not.toContain('shadowedStoredRows'); expect(JSON.parse(JSON.stringify(held))).toEqual(body('OVERRIDE', { overrides: { package: BASE, basedOn: '1.0.0' } })); expect(shadowedStoredRowsOf({ ...held })).toEqual([]); diff --git a/packages/metadata-protocol/src/stored-flow-slot.ts b/packages/metadata-protocol/src/stored-flow-slot.ts index af347018e57..c08bc22bb9d 100644 --- a/packages/metadata-protocol/src/stored-flow-slot.ts +++ b/packages/metadata-protocol/src/stored-flow-slot.ts @@ -36,6 +36,7 @@ * is carried once. Like the binding, it never leaves the process. */ +import type { InstalledManifestLookup } from '@objectstack/metadata-core'; import { isServedOverrideRowOf } from './served-override.js'; import { storedRowBindingOf } from './stored-row-binding.js'; @@ -85,7 +86,12 @@ export function shadowedStoredRowsOf(item: unknown): readonly object[] { * Returns `true` when `incoming` takes the slot, `false` when the held * declared override keeps it. Records the row that loses on the one that wins. */ -export function incomingTakesStoredFlowSlot(held: unknown, incoming: unknown, basePackageId: unknown): boolean { +export function incomingTakesStoredFlowSlot( + held: unknown, + incoming: unknown, + basePackageId: unknown, + installed: InstalledManifestLookup, +): boolean { const heldBinding = storedRowBindingOf(held); // An empty slot, or one holding no stored row: nothing to settle. if (heldBinding === undefined || held === null || typeof held !== 'object') return true; @@ -97,7 +103,7 @@ export function incomingTakesStoredFlowSlot(held: unknown, incoming: unknown, ba return true; } const isOverride = (item: unknown, binding: string | null | undefined) => - isServedOverrideRowOf({ type: 'flow', package_id: binding ?? null, metadata: item }, basePackageId); + isServedOverrideRowOf({ type: 'flow', package_id: binding ?? null, metadata: item }, basePackageId, installed); const incomingIsOverride = isOverride(incoming, incomingBinding); if (!incomingIsOverride && isOverride(held, heldBinding)) { carryShadowed(held, held, incoming); diff --git a/packages/objectql/src/plugin-authored-actions.test.ts b/packages/objectql/src/plugin-authored-actions.test.ts index 5bef2246fc3..a3e14017423 100644 --- a/packages/objectql/src/plugin-authored-actions.test.ts +++ b/packages/objectql/src/plugin-authored-actions.test.ts @@ -19,6 +19,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ObjectQLPlugin } from './plugin.js'; import { ObjectQL } from './engine.js'; import { isServedOverrideRowOf } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); type AnyRecord = Record; @@ -448,7 +452,7 @@ describe('[#30065] a declared .custom action override rebinds the body, and rest expect(warn.mock.calls[0][1]).toMatchObject({ package: `app:${BASE}`, replacedPackage: 'metadata-service' }); // The reads judge the row against the real base by the one served-override // predicate, and decline it: the run now agrees with them. - expect([isServedOverrideRowOf(row as any, BASE), isServedOverrideRowOf(row as any, OTHER)]).toEqual([false, true]); + expect([isServedOverrideRowOf(row as any, BASE, NOTHING_INSTALLED), isServedOverrideRowOf(row as any, OTHER, NOTHING_INSTALLED)]).toEqual([false, true]); // The next resync judges the row against the registered artifact and // leaves the base's handler bound. await resync([row]); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index d4133a9efc5..b54d36f2d16 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -11,7 +11,7 @@ import { import { OWNERSHIP_QUEUE_SOURCE_SLOT, describeMissingOwnershipQueueService } from './assignment-rules.js'; import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; import { assembleMetadataProtocol, isServedOverrideRowOf } from '@objectstack/metadata-protocol'; -import { isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; +import { installedManifestsOf, isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; import { resolveArtifactPackageEntries, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core'; @@ -3839,14 +3839,15 @@ export class ObjectQLPlugin implements Plugin { private isDeclaredArtifactActionOverride(row: unknown, action: any): boolean { const registry: any = this.ql?.registry; if (!registry || typeof registry.getArtifactItem !== 'function') return false; + const installed = installedManifestsOf(registry); const artifact: any = registry.getArtifactItem('action', action.name); if (artifact === undefined || artifact === null) { - return isServedOverrideRowOf(row as any, action?.overrides?.package); + return isServedOverrideRowOf(row as any, action?.overrides?.package, installed); } const base = artifact?._packageId; if (typeof base !== 'string' || base === '') return false; if (standaloneActionOwnerKey(artifact) !== standaloneActionOwnerKey(action)) return false; - return isServedOverrideRowOf(row as any, base); + return isServedOverrideRowOf(row as any, base, installed); } /** diff --git a/packages/objectql/src/protocol-custom-override-effective-read.test.ts b/packages/objectql/src/protocol-custom-override-effective-read.test.ts index 0f2815cd811..cc696f96d8b 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -43,9 +43,12 @@ import { isServedOverrideRowOf, resetEnvWritableMetadataTypes, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const DEPENDENT = 'com.default.acme-custom'; @@ -165,8 +168,9 @@ const listBody = (columns: readonly string[]) => ({ }, }); -function baseManifest(): Record { +function baseManifest(extra: Record = {}): Record { return { + ...extra, id: BASE, name: 'showcase', version: '1.0.0', @@ -191,7 +195,7 @@ function baseManifest(): Record { }; } -async function boot() { +async function boot(base: Record = baseManifest()) { const engine = new ObjectQL(); const { driver, stores } = makeMemoryDriver(); engine.registerDriver(driver, true); @@ -200,7 +204,7 @@ async function boot() { for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { engine.registry.registerObject(o as any, 'test-package'); } - engine.registerApp(baseManifest()); + engine.registerApp(base); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); const rowsOf = (name: string) => Array.from(stores.get('sys_metadata')?.values() ?? []) .filter((r) => r.type === 'view' && r.name === name); @@ -308,7 +312,7 @@ describe('[#28409] the five reads of a name X ships serve X\'s declared `.custom await install(protocol, PAIR); await saveInto(protocol, PAIR, OVERRIDE_COLUMNS, { overrides: { ...DECLARATION } }); expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowsOf(VIEW)[0] as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowsOf(VIEW)[0] as any, BASE, NOTHING_INSTALLED)).toBe(true); const packageless = await read(protocol); expect(columnsOf(packageless)).toEqual(OVERRIDE_COLUMNS); @@ -458,3 +462,64 @@ describe('[#28409] a same-named item outside X\'s closure is served under its ow expect(columnsOf(await read(protocol, OUTSIDE))).toEqual(DEPENDENT_COLUMNS); }); }); + +/** + * [#29594, ruling record 6691217245] The ensure door creates no pair for a + * kernel package (scope `system` / `cloud`) in this version. Before that + * verdict it did, so a deployment may already hold a kernel base's + * `.custom` with overrides in it. The verdict refuses the ENSURE call + * only: the pair stays installed, the overrides in it stay served by every + * read of the base's name, and a further override saved into it is admitted + * by the write gate, which never asks the ensure door. The overlay-write + * closes (ADR-0146 D11) stay shut on a kernel item, naming no pair. + */ +describe('[#29594] a kernel base\'s pair an earlier version created keeps serving its overrides', () => { + it('the ensure call is refused 409; the override already in the pair is served, and another save into it lands', async () => { + const { protocol, rowsOf } = await boot(baseManifest({ scope: 'system' })); + // The pair as today's ensure door would have written it. + await install(protocol, PAIR); + await saveInto(protocol, PAIR, OVERRIDE_COLUMNS, { overrides: { ...DECLARATION } }); + expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); + + await expect(protocol.ensureCustomizationPackage({ basePackageId: BASE })) + .rejects.toMatchObject({ statusCode: 409, message: expect.stringContaining(`Package '${BASE}' is a kernel package (scope 'system')`) }); + + for (const scope of [undefined, BASE]) { + const served = await read(protocol, scope); + expect(columnsOf(served), `read ${scope ?? '(no package)'}`).toEqual(OVERRIDE_COLUMNS); + expect(served._packageId).toBe(BASE); + expect(columnsOf((await layered(protocol, scope)).effective)).toEqual(OVERRIDE_COLUMNS); + } + expect(await listed(protocol)).toEqual([[BASE, OVERRIDE_COLUMNS]]); + + // A further change to the override is still written into the pair. + await saveInto(protocol, PAIR, BASE_COLUMNS.slice(0, 3), { overrides: { ...DECLARATION } }); + expect(columnsOf(await read(protocol))).toEqual(BASE_COLUMNS.slice(0, 3)); + }); + + it('the closes keep refusing a kernel item\'s save, naming no pair: kernel objects are not customizable in this version', async () => { + const body = listBody(UNBOUND_COLUMNS); + // Control: an App's item — the package-less save and the save naming the base are closed onto the pair. + const app = await boot(); + await expect(app.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(PAIR) }); + await expect(app.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(PAIR) }); + + // A kernel item: still closed, 403 as before the kernel verdict, but + // the ensure door creates no pair for it, so neither sentence names one. + const kernel = await boot(baseManifest({ scope: 'system' })); + const packageless = await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any) + .then(() => null, (error: any) => error); + expect(packageless).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(packageless.message).toContain(`ships in kernel package '${BASE}' (scope 'system')`); + expect(packageless.message).toContain('kernel objects are not customizable in this version, so a write naming no package is refused'); + expect(packageless.message).not.toContain(PAIR); + const named = await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any) + .then(() => null, (error: any) => error); + expect(named).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(named.message).toContain(`kernel objects are not customizable in this version, so a write naming '${BASE}' is refused`); + expect(named.message).not.toContain(PAIR); + expect(kernel.rowsOf(VIEW)).toEqual([]); + }); +}); diff --git a/packages/objectql/src/protocol-flow-custom-override.test.ts b/packages/objectql/src/protocol-flow-custom-override.test.ts index 2409042c489..e0db1841319 100644 --- a/packages/objectql/src/protocol-flow-custom-override.test.ts +++ b/packages/objectql/src/protocol-flow-custom-override.test.ts @@ -30,10 +30,13 @@ import { storedRowBindingOf, SysMetadataRepository, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; import { InMemoryActionActivationStore } from './action-activation.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const DEPENDENT = 'com.default.acme-custom'; @@ -249,7 +252,7 @@ describe('[#28412] a declared .custom flow override is the packaged flow, at eve await install(protocol, PAIR); await saveInto(protocol, PAIR, 'OVERRIDE', { overrides: { ...DECLARATION } }); expect(rows().map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rows()[0] as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rows()[0] as any, BASE, NOTHING_INSTALLED)).toBe(true); for (const scope of [undefined, BASE]) { const item = await read(protocol, scope); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 128f648e6d9..74c4f43ee91 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -46,9 +46,12 @@ import { isServedOverrideRowOf, resetEnvWritableMetadataTypes, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const OTHER = 'com.example.other'; @@ -279,7 +282,7 @@ describe('[#28413] the census and the move', () => { expect(rows(VIEW).slice().sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); for (const state of ['active', 'draft']) { const moved = rowOf(VIEW, PAIR, state)!; - expect(isServedOverrideRowOf(moved as any, BASE), state).toBe(true); + expect(isServedOverrideRowOf(moved as any, BASE, NOTHING_INSTALLED), state).toBe(true); expect(JSON.parse(String(moved.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); } expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR, 'draft')!.metadata)))).toEqual(EDITED_COLUMNS); @@ -422,7 +425,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, packageId: null } as any))); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); }); } @@ -454,7 +457,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); @@ -507,7 +510,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); }); @@ -688,7 +691,7 @@ describe('[#29392] the census and the move reach a row bound to the read-only ba expect(rows(VIEW).slice().sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); for (const state of ['active', 'draft']) { const moved = rowOf(VIEW, PAIR, state)!; - expect(isServedOverrideRowOf(moved as any, BASE), state).toBe(true); + expect(isServedOverrideRowOf(moved as any, BASE, NOTHING_INSTALLED), state).toBe(true); expect(JSON.parse(String(moved.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); } expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR, 'draft')!.metadata)))).toEqual(EDITED_COLUMNS); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 689534c3d25..91d5e681746 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -44,8 +44,9 @@ import { type FieldPatchTargetMiss, // [ADR-0146 D3 · ruling record 6691022506] The pair of a package, looked up // through the App that carries it — the uninstall refusal's "is its pair - // installed?" asks it. + // installed?" asks it, over this registry's installed manifests. pairOf, + installedManifestsOf, } from '@objectstack/metadata-core'; // [ADR-0029 D9.2a] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather // than re-spelled: the object FOLD asks the same question one layer down (has @@ -7333,7 +7334,7 @@ export class SchemaRegistry { extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) }); } } - const pair = pairOf(packageId); + const pair = pairOf(packageId, installedManifestsOf(this)); const installedPair = pair.ok && this.getPackage(pair.value) !== undefined ? pair.value : undefined; if (extended.length > 0 || installedPair !== undefined) { throw new PackageUninstallExtendedError(packageId, extended, installedPair); diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index 1efe4115504..255ae242b3a 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -228,7 +228,11 @@ describe('the permission-set clone door, on a booted showcase', () => { } }); - it('a platform set (the lock-and-clone rule\'s commonest source) copies into its shipping package\'s pair', async () => { + // [#29594, ruling record 6691217245] A kernel package has no paired + // customization package, so a platform set's copy is saved where an + // environment-authored set's copy is: environment-wide, the notice naming + // the kernel package and `package`. + it('a platform set (the lock-and-clone rule\'s commonest source) copies environment-wide: the kernel has no pair', async () => { const reader = securityCatalogReaderOf(ql)!; const source = await reader.resolve('permission', 'member_default'); expect(source?.packageId, 'member_default is not packaged on this boot').toBeTruthy(); @@ -236,9 +240,12 @@ describe('the permission-set clone door, on a booted showcase', () => { const res = await clone(token, 'member_default', { name: 'my_member_baseline', label: 'My Member Baseline' }); const text = await res.clone().text(); expect(res.status, text).toBe(200); - const landed = ((await res.json()) as any).data.package; - expect(landed).toBe(`${source!.packageId}.custom`); - expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBe(landed); + const data = ((await res.json()) as any).data; + expect(data.package).toBeNull(); + expect(data.noticeCode).toBe('PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE'); + expect(data.notice).toContain(`kernel package '${source!.packageId}'`); + expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBeUndefined(); + expect((await stack.apiAs(token, 'GET', `/packages/${source!.packageId}.custom`)).status).toBe(404); }); it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => { diff --git a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts index f6c631ab5f0..9f317616b1d 100644 --- a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts @@ -33,6 +33,8 @@ * the store, and the named package are asserted — never a refusal's sentence. */ +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -63,8 +65,12 @@ afterAll(() => { /** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ const TEST_PACKAGE_ID = 'objectstack-test'; -/** The base: an installed app the customer cannot author into (a platform scope). */ -const BASE = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm', scope: 'system' }; +/** + * The base: an installed app the customer cannot author into — held as code, + * the way an installed app's packages are. Not a `system` scope: that is the + * kernel's, which has no pair in this version (ruling record 6691217245). + */ +const BASE = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm' }; const PAIR = 'com.acme.crm.custom'; /** A writable package that depends on the base: a legal explicit target. */ const EXT = { id: 'com.acme.ext', name: 'Ext', version: '1.0.0', type: 'module', namespace: 'ext', dependencies: { [BASE.id]: '^3' } }; @@ -104,7 +110,7 @@ interface Answer { * protocol refuses (an illegal machine name among it) throws the way the real * engine throws. The default rig's engine is a map that accepts anything. */ -async function boot(options: { parse?: boolean } = {}) { +async function boot(options: { parse?: boolean; baseScope?: 'system' | 'cloud' } = {}) { const engine = new ObjectQL(); liveEngines.push(engine); engine.registerDriver(new SqlDriver({ @@ -118,7 +124,9 @@ async function boot(options: { parse?: boolean } = {}) { engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); await engine.syncSchemas(); (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; - engine.registry.installPackage(BASE as any, 'marketplace'); + const base = options.baseScope === undefined ? BASE : { ...BASE, scope: options.baseScope }; + engine.registry.installPackage(base as any, 'code'); + (engine as unknown as { manifests: Map }).manifests.set(BASE.id, base); engine.registry.installPackage(EXT as any, 'code'); engine.registry.installPackage(OTHER as any, 'code'); engine.registry.installPackage(SEALED as any, 'marketplace'); @@ -252,6 +260,35 @@ describe('[#28149] the default target: the copy lands in .custom, switched }, 60_000); }); +/** + * [#29594, ruling record 6691217245] A kernel package (scope `system` / + * `cloud`) has no pair in this version: the ensure door refuses it 409. The + * flow door's default landing IS the ensure door, so a copy of a kernel-shipped + * flow with no `package` named answers that refusal as relayed, and the + * registration made before the landing is undone (`restoreFlowRegistration`): + * the engine holds no copy, nothing is stored, and no pair is created. + */ +describe('[#29594] a kernel-shipped flow: no pair to land the copy in', () => { + for (const baseScope of ['system', 'cloud'] as const) { + it(`scope '${baseScope}', no \`package\` named: 409 with the ensure door's sentence, and the engine holds no copy`, async () => { + const rig = await boot({ baseScope }); + const before = [...rig.flows.keys()].sort(); + + const answer = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'Lead follow-up (ACME)' }); + + expect(answer.status, JSON.stringify(answer.body)).toBe(409); + expect(answer.code).toBe('RESOURCE_CONFLICT'); + expect(JSON.stringify(answer.body)).toContain(`Package '${BASE.id}' is a kernel package (scope '${baseScope}')`); + expect(rig.flows.has('acme_lead_followup')).toBe(false); + expect([...rig.flows.keys()].sort()).toEqual(before); + expect(await rig.rows('acme_lead_followup')).toEqual([]); + expect(rig.installed()).not.toContain(PAIR); + // The original keeps running, untouched. + expect(rig.flows.get(SOURCE)?.status).toBe('active'); + }, 60_000); + } +}); + describe('[#29650, ADR-0146 D2/D4] New similar while the source\'s override is live', () => { /** * Customize the base's flow the way Studio does: ensure the pair, save the diff --git a/packages/runtime/src/domains/packages-ensure-customization.test.ts b/packages/runtime/src/domains/packages-ensure-customization.test.ts index 3f818e28c16..2159692f5a8 100644 --- a/packages/runtime/src/domains/packages-ensure-customization.test.ts +++ b/packages/runtime/src/domains/packages-ensure-customization.test.ts @@ -22,6 +22,8 @@ * really install. */ +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -58,8 +60,13 @@ const TENANT_ADMIN = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: /** The platform operator rung. */ const OPERATOR = { userId: 'u_operator', systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }; -/** The base: an installed app the customer cannot author into (a platform scope). */ -const CRM = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm', scope: 'system' }; +/** + * The base: an installed app the customer cannot author into — read-only + * because the runtime holds it as code, the way an installed app's packages + * are held. Not a `system` scope: that is the kernel's, which has no pair in + * this version (ruling record 6691217245). + */ +const CRM = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm' }; const PAIR_ID = 'com.acme.crm.custom'; const ENSURE_PATH = `POST /api/v1/packages/${CRM.id}/ensure-customization`; @@ -126,9 +133,10 @@ function register(d: Door, manifest: Record, source: 'marketpla d.registry.installPackage(manifest as any, source); } -/** The base installed as production holds it: an installed app of a platform scope. */ +/** The base installed as production holds it: an installed app, held as code. */ function withBase(d: Door, base: Record = CRM): Door { - register(d, base); + register(d, base, 'code'); + d.engine.manifests.set(base.id as string, base); return d; } @@ -239,6 +247,38 @@ describe(`${ENSURE_PATH}: each refusal is worded and leaves nothing`, () => { expectRefusal(r, 409, 'RESOURCE_CONFLICT', `Package '${own.id}' is writable: customize it directly`); }); + // [ruling record 6691217245] The kernel — the packages of scope `system` + // and `cloud` — ships with the runtime, at its version: no kernel + // object is customizable in this version, so the door ensures no pair for + // one. A narrowing: before it, this call created `.custom`. + for (const scope of ['system', 'cloud'] as const) { + it(`a kernel base (scope '${scope}'): 409, not customizable in this version, nothing created`, async () => { + const d = door(); + const kernel = { id: 'com.objectstack.plugin-auth', name: 'Auth', version: '18.3.0', type: 'plugin', namespace: 'sys', scope }; + register(d, kernel, 'code'); + const r = await leavesNothing(d, () => ensure(d, kernel.id)); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', + `Package '${kernel.id}' is a kernel package (scope '${scope}')`, + 'kernel objects are not customizable in this version', + `'${kernel.id}.custom' is not ensured`); + expect(d.registry.getPackage(`${kernel.id}.custom`)).toBeUndefined(); + }); + } + + it('a kernel pair an earlier version created stays installed: only the ensure call is refused', async () => { + const d = door(); + const kernel = { id: 'com.objectstack.plugin-auth', name: 'Auth', version: '18.3.0', type: 'plugin', namespace: 'sys', scope: 'system' }; + register(d, kernel, 'code'); + // The pair today's door would have created for it, as it would have written it. + const legacy = { id: `${kernel.id}.custom`, name: 'Auth (custom)', version: '0.1.0', type: 'module', namespace: 'sys_custom', dependencies: { [kernel.id]: '^18' } }; + expect((await install(d, legacy)).response.status).toBe(201); + const before = structuredClone(d.registry.getPackage(legacy.id)); + const r = await leavesNothing(d, () => ensure(d, kernel.id)); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', 'A pair an earlier version created stays installed'); + expect(d.registry.getPackage(legacy.id)).toEqual(before); + expect((await read(d, legacy.id)).response.status).toBe(200); + }); + it('the base is itself a pair: 400, a pair is never paired again', async () => { const d = withBase(door()); expect((await ensure(d, CRM.id)).response.status).toBe(201); @@ -248,13 +288,13 @@ describe(`${ENSURE_PATH}: each refusal is worded and leaves nothing`, () => { }); it('the base declares no namespace: 409', async () => { - const d = withBase(door(), { id: 'com.acme.bare', name: 'Bare', version: '1.0.0', type: 'app', scope: 'system' }); + const d = withBase(door(), { id: 'com.acme.bare', name: 'Bare', version: '1.0.0', type: 'app' }); const r = await leavesNothing(d, () => ensure(d, 'com.acme.bare')); expectRefusal(r, 409, 'RESOURCE_CONFLICT', "package 'com.acme.bare' declares no namespace"); }); it('the composed namespace fails the manifest grammar: 409, never truncated', async () => { - const d = withBase(door(), { id: 'com.acme.long', name: 'Long', version: '1.0.0', type: 'app', namespace: 'abcdefghijklmn', scope: 'system' }); + const d = withBase(door(), { id: 'com.acme.long', name: 'Long', version: '1.0.0', type: 'app', namespace: 'abcdefghijklmn' }); const r = await leavesNothing(d, () => ensure(d, 'com.acme.long')); expectRefusal(r, 409, 'RESOURCE_CONFLICT', "'abcdefghijklmn_custom' exceeds the 20 characters", 'none is truncated or hashed'); }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index efdec37c82f..d796baff1bd 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -37,7 +37,7 @@ import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, isCodeArtifactBody } from // sources, each read from its owner: the pair's id and a pair's base // (`@objectstack/spec/kernel`, the one spelling of the suffix), and the // activation ledger's one reader (`@objectstack/core`). -import { customizationBasePackageId, customizationPackageId } from '@objectstack/spec/kernel'; +import { customizationBasePackageId } from '@objectstack/spec/kernel'; import { listPackageMetadataActivations, METADATA_ACTIVATION_TABLE } from '@objectstack/core'; import type { GetPackageCustomizationsResponse } from '@objectstack/spec/api'; // [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate, asked by @@ -57,6 +57,11 @@ import { isProtocolIncompatibleError, protocolIncompatibleAnswer, type InstalledDependencyRecord, + // [ADR-0146 D3 · ruling record 6691022506] The pair of a package, looked up + // through the App that carries it, over the engine registry's manifests. + installedManifestsOf, + pairOf, + type InstalledPackageSource, } from '@objectstack/metadata-core'; // [#7560] ADR-0070's read-only-package rule — the SAME predicate the metadata // authoring path asks before refusing a write INTO a platform package @@ -657,19 +662,21 @@ function packageIsUnknown(registry: unknown, id: string): boolean { * `capability`, the code-only types) is refused at the save door naming * the route that type has ("new similar", the switch), and a locked item * `ITEM_LOCKED` naming the lock (ADR-0010 §3.3); - * - a package the ensure door cannot pair — no namespace, a pair namespace - * another package owns, a version that is not SemVer — is refused there, + * - a package the ensure door cannot pair — a kernel package, no namespace, + * a pair namespace another package owns, a version that is not SemVer — is + * refused there, * naming why (ADR-0146 D3). * Both refusals name their own reason, so the sentence names the door rather * than a list of types or package shapes that could drift from them. * - * An id the pairing rule derives no pair for (`customizationPackageId`, - * `@objectstack/spec/kernel`) gets the sentence without a pair id: the ensure - * door answers that id with the reason it has none. + * An id the pair lookup derives no pair for (`pairOf`, + * `@objectstack/metadata-core`, over the engine registry's installed + * manifests) gets the sentence without a pair id: the ensure door answers that + * id with the reason it has none. */ -function customizeInPairRemedy(id: string): string { +function customizeInPairRemedy(id: string, engine: unknown): string { const ensure = `POST /api/v1/packages/${id}/ensure-customization`; - const pair = customizationPackageId(id); + const pair = pairOf(id, installedManifestsOf((engine as { registry?: InstalledPackageSource } | null | undefined)?.registry)); if (!pair.ok) { return `customize what it ships in its paired customization package (ADR-0146 D3): ${ensure} answers ` + 'that package, or why this one has none.'; @@ -694,7 +701,7 @@ function requireWritablePackage( `[writable_package_required] Cannot ${action} package '${id}': it is read-only ` + `(provided by code or an installed app). Packages the deployment ships are managed by ` + `the deployment, not over this API — ${action} a package you own, or ` - + customizeInPairRemedy(id), + + customizeInPairRemedy(id, engine), 422, { code: 'WRITABLE_PACKAGE_REQUIRED', @@ -1492,7 +1499,7 @@ function requireDuplicableSource( + `(provided by code or an installed app), and duplicate clones a writable base's ` + `stored metadata rows. A code package's metadata is delivered as code, so there are ` + `no rows to clone and the copy would be empty (ADR-0070 D4 duplicates a BASE). ` - + `Duplicate a base you own, or ${customizeInPairRemedy(id)} ` + + `Duplicate a base you own, or ${customizeInPairRemedy(id, engine)} ` + `For an editable copy of the whole package, install it unmanaged instead ` + `(installMode 'unmanaged'), where its manifest's installModes permits that.`, 422, @@ -3982,8 +3989,8 @@ async function readPackageCustomizations( ): Promise { const overrides = await listServedOverrides({ packageId: baseId }); - const pair = customizationPackageId(baseId); const registry = ql?.registry; + const pair = pairOf(baseId, installedManifestsOf(registry)); type Extension = GetPackageCustomizationsResponse['extensions'][number]; const extensions: Extension[] = []; if (pair.ok && typeof registry?.getAllObjects === 'function') { diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 63d307abd73..9a23ebacf91 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -21,6 +21,8 @@ // there; a given target is judged by the shared new-similar check; an // environment-authored source is unchanged. +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect } from 'vitest'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -67,7 +69,10 @@ const SYS_ONLY_SET = { name: 'sys_only', label: 'Sys Only', objects: { sys_user: /** A set `crm` ships that grants on a platform object only: its package is the one thing out of an island's reach. */ const CRM_SYS_SET = { name: 'crm_user_reader', label: 'CRM User Reader', objects: { sys_user: { allowRead: true } }, _packageId: 'crm' }; -const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET, CRM_SYS_SET] }; +/** [#29594] A set the platform's kernel ships (scope `system`): the commonest lock-and-clone source. */ +const KERNEL = 'com.objectstack.plugin-security'; +const KERNEL_SET = { name: 'member_default', label: 'Member', objects: { account: { allowRead: true } }, _packageId: KERNEL, _lock: 'full' }; +const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET, CRM_SYS_SET, KERNEL_SET] }; /** * The package registry the door reads: `crm` is a booted CODE package @@ -82,6 +87,7 @@ const PACKAGES: Record }> = { 'com.acme.ext': { manifest: { id: 'com.acme.ext', dependencies: { crm: '^1' } } }, 'com.acme.island': { manifest: { id: 'com.acme.island' } }, 'com.vendor.app': { manifest: { id: 'com.vendor.app', scope: 'cloud' } }, + [KERNEL]: { manifest: { id: KERNEL, namespace: 'sys', version: '18.3.0', scope: 'system' } }, }; interface Saved { type: string; name: string; item: Record; parentVersion?: string | null; packageId?: string; inheritedGrantObjects?: readonly string[] } @@ -238,6 +244,22 @@ describe('the permission-set clone door — success', () => { expect((res as any).response.body.data.notice).toBe(permissionSetCloneNotice({ packageId: null, created: false })); }); + it('[#29594] a set a kernel package ships copies environment-wide, with no ensure: the kernel has no pair', async () => { + const h = boot(); + + const res = await clone(h, HOLDER(), KERNEL_SET.name, { name: 'my_member', label: 'My Member' }); + + expect(statusOf(res)).toBe(200); + expect(h.ensures).toEqual([]); + expect(h.saves[0]).not.toHaveProperty('packageId'); + expect(h.saves[0].item).toEqual({ name: 'my_member', label: 'My Member', objects: KERNEL_SET.objects }); + const data = (res as any).response.body.data; + expect(data.package).toBeNull(); + expect(data.noticeCode).toBe('PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE'); + expect(data.notice).toBe(permissionSetCloneNotice({ packageId: null, created: false, kernelSource: KERNEL })); + expect(data.notice).toContain(`kernel package '${KERNEL}'`); + }); + it('a set already in a writable package copies into that package, with no ensure', async () => { const h = boot(); @@ -331,7 +353,7 @@ describe('the permission-set clone door — authority', () => { }); describe('[#29727] the clone notice is named by one stable code per landing', () => { - // CONTROL: the three sentences, spelled out as they were served before the + // CONTROL: the sentences (the fourth since #29594), spelled out as they were served before the // codes arrived, so a change to `notice` while adding a code reds here. const TAIL = 'The copy is a new permission set authored in this system, with no link to the set it was copied ' @@ -344,6 +366,13 @@ describe('[#29727] the clone notice is named by one stable code per landing', () code: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE', notice: 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + TAIL, }, + { + landing: { packageId: null, created: false, kernelSource: 'com.objectstack.plugin-security' }, + code: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE', + notice: "The copy was saved environment-wide, in no package: the set it was copied from is shipped by kernel " + + "package 'com.objectstack.plugin-security', which has no paired customization package in this version. " + + 'Name a writable package with `package` to keep the copy in it instead. ' + TAIL, + }, { landing: { packageId: 'com.acme.base', created: false }, code: 'PERMISSION_SET_CLONE_IN_PACKAGE', @@ -363,9 +392,9 @@ describe('[#29727] the clone notice is named by one stable code per landing', () expect(permissionSetCloneNotice(landing)).toBe(notice); }); - it('the three codes are distinct and the table holds exactly them', () => { + it('the four codes are distinct and the table holds exactly them', () => { expect(Object.values(PERMISSION_SET_CLONE_NOTICE_CODES).sort()).toEqual(VARIANTS.map((v) => v.code).sort()); - expect(new Set(VARIANTS.map((v) => permissionSetCloneNotice(v.landing))).size).toBe(3); + expect(new Set(VARIANTS.map((v) => permissionSetCloneNotice(v.landing))).size).toBe(4); }); }); diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index a46dc08a5e4..923ccfc2ebe 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -74,6 +74,13 @@ * grants on; the one check both "new similar" doors ask * (`newSimilarTargetRefusal`, `./new-similar-target.ts`), else refused with * nothing written; + * - **omitted, the source shipped by a kernel package** (scope `system` / + * `cloud`: the platform's own sets, `member_default` among them) — the + * save is environment-wide, as for a source authored in the environment: + * a kernel package has no paired customization package in this version + * (ruling record 6691217245), and the copy is a new set, not a change to + * the kernel's. The notice says why, and that `package` keeps the copy in + * a package the caller names; * - **omitted, the source shipped by a read-only package X** — X's paired * customization package `.custom`, answered by N-13's ensure door * (`ensureCustomizationPackage`, `@objectstack/metadata-protocol`), the @@ -110,7 +117,7 @@ import { SnakeCaseIdentifierSchema } from '@objectstack/spec/shared'; import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; -import { isWritablePackage, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { isWritablePackage, kernelPackageScope, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { newSimilarTargetRefusal, newSimilarTargetRefusalAnswer } from './new-similar-target.js'; import { validationFailure } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -158,6 +165,11 @@ export const PERMISSION_SET_CLONE_NOTICE = export interface PermissionSetCloneLanding { readonly packageId: string | null; readonly created: boolean; + /** + * The kernel package that shipped the source, when the copy went + * environment-wide because that package has no pair; absent otherwise. + */ + readonly kernelSource?: string; } /** @@ -173,16 +185,17 @@ export interface PermissionSetCloneLanding { */ export const PERMISSION_SET_CLONE_NOTICE_CODES = Object.freeze({ environmentWide: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE', + environmentWideKernelSource: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE', inPackage: 'PERMISSION_SET_CLONE_IN_PACKAGE', inCreatedPackage: 'PERMISSION_SET_CLONE_IN_CREATED_PACKAGE', } as const); -/** Which of the three notices a landing is told with. */ +/** Which of the four notices a landing is told with. */ export type PermissionSetCloneLandingKind = keyof typeof PERMISSION_SET_CLONE_NOTICE_CODES; /** The one place a landing is sorted into its notice variant. */ export function permissionSetCloneLandingKind(landing: PermissionSetCloneLanding): PermissionSetCloneLandingKind { - if (landing.packageId === null) return 'environmentWide'; + if (landing.packageId === null) return landing.kernelSource !== undefined ? 'environmentWideKernelSource' : 'environmentWide'; return landing.created ? 'inCreatedPackage' : 'inPackage'; } @@ -196,6 +209,12 @@ export function permissionSetCloneNotice(landing: PermissionSetCloneLanding): st return 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + PERMISSION_SET_CLONE_NOTICE; } + if (kind === 'environmentWideKernelSource') { + return `The copy was saved environment-wide, in no package: the set it was copied from is shipped by kernel ` + + `package '${landing.kernelSource}', which has no paired customization package in this version. Name a ` + + 'writable package with `package` to keep the copy in it instead. ' + + PERMISSION_SET_CLONE_NOTICE; + } return `The copy was saved in package '${landing.packageId}'` + (kind === 'inCreatedPackage' ? ', created for it to hold your changes to the package the set came from' : '') + ', beside the original rather than in place of it, and travels with that package. ' @@ -297,6 +316,11 @@ export async function resolveDefaultCloneLanding( const sourcePackageId = source.packageId; if (sourcePackageId === undefined) return { packageId: null, created: false }; if (isWritablePackage(ql, sourcePackageId)) return { packageId: sourcePackageId, created: false }; + // [ruling record 6691217245] A kernel package has no pair: the copy of + // its set is a new set, saved where an environment-authored set's is. + if (kernelPackageScope(ql, sourcePackageId) !== undefined) { + return { packageId: null, created: false, kernelSource: sourcePackageId }; + } if (typeof protocol.ensureCustomizationPackage !== 'function') { throw Object.assign( new Error( diff --git a/packages/services/service-automation/src/flow-name-shadowing.test.ts b/packages/services/service-automation/src/flow-name-shadowing.test.ts index a395ae6fd3c..190cfd07835 100644 --- a/packages/services/service-automation/src/flow-name-shadowing.test.ts +++ b/packages/services/service-automation/src/flow-name-shadowing.test.ts @@ -32,6 +32,10 @@ import { SchemaRegistry } from '@objectstack/objectql'; import { AutomationEngine } from './engine.js'; import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js'; import { bindStoredRow } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const FLOW = 'opportunity_approval'; const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any; @@ -92,7 +96,7 @@ function bootPull(registry: any, logger: { warn(m: string, meta?: unknown): void const engine = new AutomationEngine(silentLogger); engine.setPackagedFlowSource(loaderSetOf(registry)); const listed = registry.listItems('flow') as unknown[]; - const resolved = resolveFlowPrecedence(listed, logger, (name) => engine.packagedFlowOwner(name)); + const resolved = resolveFlowPrecedence(listed, logger, (name) => engine.packagedFlowOwner(name), NOTHING_INSTALLED); for (const entry of resolved) { engine.registerFlow(entry.name, entry.definition as never); if (entry.shadowing) engine.recordFlowShadowing(entry.shadowing); @@ -212,8 +216,8 @@ describe('#11997 — precedence is a total order, not an iteration order', () => // The loader's set names ONE owner per name; the second package's // entry is told apart by the registry's per-entry artifact test. const loaderSet = setOf({ [FLOW]: 'alpha' }); - const forward = resolveFlowPrecedence([a, b], silentLogger, loaderSet); - const backward = resolveFlowPrecedence([b, a], silentLogger, loaderSet); + const forward = resolveFlowPrecedence([a, b], silentLogger, loaderSet, NOTHING_INSTALLED); + const backward = resolveFlowPrecedence([b, a], silentLogger, loaderSet, NOTHING_INSTALLED); expect((forward[0].definition as any).label).toBe('FROM_ALPHA'); expect((backward[0].definition as any).label).toBe('FROM_ALPHA'); @@ -225,7 +229,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () => const after = { ...flowBody('zzz_last', 'Z') }; const resolved = resolveFlowPrecedence( [before, packagedBody(), after, runtimeBody()], - silentLogger, + silentLogger, undefined, NOTHING_INSTALLED, ); expect(resolved.map((r) => r.name)).toEqual(['aaa_first', FLOW, 'zzz_last']); }); @@ -237,16 +241,16 @@ describe('#11997 — precedence is a total order, not an iteration order', () => // `loadMetaFromDb` rehydrates overlay rows with a synthetic // `_packageId = 'sys_metadata'` (ADR-0005 §Provenance edge case). A // bare `_packageId` truthiness test would misread it as packaged. - expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet)).toEqual({ + expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'sys_metadata', }); // And a tenant-authored overlay bound to a REAL package id is runtime // too — provenance is the axis, not the id (cloud#970). expect( - describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet), + describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet, NOTHING_INSTALLED), ).toEqual({ source: 'runtime', packageId: 'app.built_by_studio' }); - expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet)).toEqual({ + expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet, NOTHING_INSTALLED)).toEqual({ source: 'package', packageId: 'crm', }); @@ -256,7 +260,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () => const packaged = { ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' }; const tenant = { ...flowBody(FLOW, 'TENANT'), _packageId: 'crm', _provenance: 'org' }; for (const listed of [[packaged, tenant], [tenant, packaged]]) { - const resolved = resolveFlowPrecedence(listed, silentLogger, setOf({ [FLOW]: 'crm' })); + const resolved = resolveFlowPrecedence(listed, silentLogger, setOf({ [FLOW]: 'crm' }), NOTHING_INSTALLED); expect((resolved[0].definition as any).label).toBe('PACKAGED'); } }); diff --git a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts index 918d57ff43e..e5b78f2a85d 100644 --- a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts +++ b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts @@ -22,6 +22,10 @@ import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine, FlowContender } from './engine.js'; import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js'; import { bindStoredRow } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const FLOW = 'order_sync'; @@ -59,29 +63,29 @@ const silent = () => ({ warn: vi.fn() }); describe('[#20864] boot-time precedence classifies by the loader\'s set, not by body stamps', () => { it('a contender claiming a package ranks as tenant-authored when the set does not hold its name', () => { const claimed = loaderEntry(); - expect(describeFlowContender(claimed, HOLDS_NOTHING)).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(claimed, HOLDS_NOTHING, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); // The set is keyed by NAME: holding another flow vouches for nothing here. - expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }))).toEqual({ + expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }), NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm', }); // The operator reads it as the tenant row it is; the claimed package // never reaches the sentence. - expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING))).toBe( + expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING, NOTHING_INSTALLED))).toBe( renderFlowContender({ source: 'runtime' }), ); }); it('the same contender ranks as packaged when the set holds its name', () => { - expect(describeFlowContender(loaderEntry(), HOLDS_FLOW)).toEqual({ source: 'package', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), HOLDS_FLOW, NOTHING_INSTALLED)).toEqual({ source: 'package', packageId: 'crm' }); }); it('a stored tenant row stays tenant-authored inside a name the set holds', () => { - expect(describeFlowContender(tenantRow(), HOLDS_FLOW)).toEqual({ source: 'runtime', packageId: 'app.ops' }); + expect(describeFlowContender(tenantRow(), HOLDS_FLOW, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'app.ops' }); }); it('the same two bodies rank by the set: only a held name puts a packaged contender in the shadowing record', () => { - const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW); + const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW, NOTHING_INSTALLED); expect(held).toHaveLength(1); // [#20913, ADR-0126 §2] Inside a held name the loader's entry is armed. expect((held[0].definition as { label: string }).label).toBe('LOADER'); @@ -91,7 +95,7 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by shadowed: [{ source: 'runtime', packageId: 'app.ops' }], }); - const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); + const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect(unheld[0].shadowing).toEqual({ name: FLOW, armed: { source: 'runtime', packageId: 'app.ops' }, @@ -105,7 +109,7 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by [tenantRow(), loaderEntry()], ]) { const logger = silent(); - const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW); + const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('LOADER'); expect(winner.shadowing?.armed).toEqual({ source: 'package', packageId: 'crm' }); expect(winner.shadowing?.shadowed).toEqual([{ source: 'runtime', packageId: 'app.ops' }]); @@ -123,27 +127,27 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by // Both tenant-ranked (the set holds nothing). By id alone the tenant // row would sort first; arrival order decides instead, so a claimed id // cannot buy the armed slot. - const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING); + const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect((first[0].definition as { label: string }).label).toBe('LOADER'); - const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); + const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect((second[0].definition as { label: string }).label).toBe('TENANT'); }); it('with no reader nothing is packaged: the engine\'s fail-closed answer', () => { - expect(describeFlowContender(loaderEntry())).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), undefined, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); - const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent()); + const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), undefined, NOTHING_INSTALLED); const record = winner.shadowing!; expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']); // A reader answering an empty owner names no package either — the // normalization `AutomationEngine.packagedFlowOwner` applies. - expect(describeFlowContender(loaderEntry(), () => '')).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), () => '', NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); }); it('asks the set once per contested name, and never for an uncontested one', () => { const reader = vi.fn(HOLDS_FLOW); - resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader); + resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader, NOTHING_INSTALLED); expect(reader.mock.calls).toEqual([[FLOW]]); }); }); @@ -170,14 +174,14 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod bindStoredRow(flowBody(FLOW, label, { ...body, _provenance: 'org' }), binding); it('classifies the pair\'s declared row as the override, by its stored binding', () => { - expect(describeFlowContender(stored(PAIR), HOLDS)).toEqual({ source: 'override', packageId: PAIR }); + expect(describeFlowContender(stored(PAIR), HOLDS, NOTHING_INSTALLED)).toEqual({ source: 'override', packageId: PAIR }); expect(renderFlowContender({ source: 'override', packageId: PAIR })).toBe(`the declared override in '${PAIR}' (sys_metadata)`); }); it('arms the override in either arrival order, with no warning and no receipt', () => { for (const listed of [[base(), stored(PAIR)], [stored(PAIR), base()]]) { const logger = silent(); - const winners = resolveFlowPrecedence(listed, logger, HOLDS); + const winners = resolveFlowPrecedence(listed, logger, HOLDS, NOTHING_INSTALLED); expect(winners).toHaveLength(1); expect((winners[0].definition as { label: string }).label).toBe('OVERRIDE'); expect(winners[0].shadowing).toBeUndefined(); @@ -194,7 +198,7 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod ] as const) { it(`${label} is not armed: the loader's body is, and the remedy names the declaration, not a clone`, () => { const logger = silent(); - const [winner] = resolveFlowPrecedence([row(), base()], logger, HOLDS); + const [winner] = resolveFlowPrecedence([row(), base()], logger, HOLDS, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('LOADER'); expect(winner.shadowing?.armed).toEqual({ source: 'package', packageId: OWNER }); expect(winner.shadowing?.shadowed.map((c) => c.source)).toEqual(['runtime']); @@ -206,12 +210,12 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod } it('an unheld name: a declared row is tenant-authored, the set vouches for no override', () => { - expect(describeFlowContender(stored(PAIR), HOLDS_NOTHING).source).toBe('runtime'); + expect(describeFlowContender(stored(PAIR), HOLDS_NOTHING, NOTHING_INSTALLED).source).toBe('runtime'); }); it('the override beside an undeclared stored row: the override is armed, the stored row shadowed and warned', () => { const logger = silent(); - const [winner] = resolveFlowPrecedence([stored(null, {}, 'TENANT'), base(), stored(PAIR)], logger, HOLDS); + const [winner] = resolveFlowPrecedence([stored(null, {}, 'TENANT'), base(), stored(PAIR)], logger, HOLDS, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('OVERRIDE'); expect(winner.shadowing).toEqual({ name: FLOW, diff --git a/packages/services/service-automation/src/flow-precedence.ts b/packages/services/service-automation/src/flow-precedence.ts index 92373bbe509..25db2b1c807 100644 --- a/packages/services/service-automation/src/flow-precedence.ts +++ b/packages/services/service-automation/src/flow-precedence.ts @@ -85,7 +85,7 @@ // tsup config externalises only `dependencies`/`peerDependencies` the bundler // answered it by inlining objectql's implementation into this package's dist. // `pnpm check:undeclared-dep-imports` is the gate over that class. -import { isCodeArtifactBody, pairOf } from '@objectstack/metadata-core'; +import { isCodeArtifactBody, pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; // [#28412, ADR-0146 D9 item 1] The one served-override predicate (N-10) and // the stored row's own binding the protocol's hydration records — never a // second spelling of "is this the declared override", and never the body's @@ -140,13 +140,13 @@ function loaderSetOwner(packagedFlowOwner: PackagedFlowSource | undefined, name: * `runtime` contender keeps a body's id for display only — * {@link renderFlowContender} never prints it. */ -function classifyContender(item: unknown, owner: string | undefined): FlowContender { +function classifyContender(item: unknown, owner: string | undefined, installed: InstalledManifestLookup): FlowContender { const packageId = (item as { _packageId?: unknown } | null | undefined)?._packageId; // [#28412, ADR-0146 D9 item 1] Asked FIRST, and only inside a held name: // the owner's declared override, by its stored binding (see the header). if (owner !== undefined) { const binding = storedRowBindingOf(item); - if (isServedOverrideRowOf({ type: 'flow', package_id: binding, metadata: item }, owner)) { + if (isServedOverrideRowOf({ type: 'flow', package_id: binding, metadata: item }, owner, installed)) { return { source: 'override', packageId: binding as string }; } } @@ -181,9 +181,13 @@ function classifyContender(item: unknown, owner: string | undefined): FlowConten * cannot tell a tenant overlay bound to a package from a code artifact (see * `isTenantAuthored` in `@objectstack/metadata-core`, and cloud#970). */ -export function describeFlowContender(item: unknown, packagedFlowOwner?: PackagedFlowSource): FlowContender { +export function describeFlowContender( + item: unknown, + packagedFlowOwner: PackagedFlowSource | undefined, + installed: InstalledManifestLookup, +): FlowContender { const name = (item as { name?: unknown } | null | undefined)?.name; - return classifyContender(item, loaderSetOwner(packagedFlowOwner, name)); + return classifyContender(item, loaderSetOwner(packagedFlowOwner, name), installed); } /** @@ -254,8 +258,8 @@ function precedenceRank(contender: FlowContender): number { * ⛔ No longer "clone": the clone is a second flow beside the base, and both * run. */ -function undeclaredRemedy(owner: string | undefined): string { - const pair = owner === undefined ? undefined : pairOf(owner); +function undeclaredRemedy(owner: string | undefined, installed: InstalledManifestLookup): string { + const pair = owner === undefined ? undefined : pairOf(owner, installed); const where = pair?.ok ? `declare it as an override in '${pair.value}' (overrides: { package: '${owner}', basedOn })` : 'declare it as an override in the shipping package\'s paired .custom package'; @@ -304,11 +308,16 @@ function undeclaredRemedy(owner: string | undefined): string { * @param logger warned once per colliding name, naming both contenders * @param packagedFlowOwner the loader's set — the boot pull passes the engine's * `packagedFlowOwner`; absent, no contender is packaged + * @param installed the installed manifests of the registry the flows + * come from: the declared override of a shipped flow + * is its App's pair's (`pairOf` in + * `@objectstack/metadata-core`) */ export function resolveFlowPrecedence( items: readonly unknown[], - logger?: { warn(message: string, meta?: unknown): void }, - packagedFlowOwner?: PackagedFlowSource, + logger: { warn(message: string, meta?: unknown): void } | undefined, + packagedFlowOwner: PackagedFlowSource | undefined, + installed: InstalledManifestLookup, ): FlowPrecedenceWinner[] { // Group by bare name, remembering arrival order for a stable tie-break. const groups = new Map>(); @@ -336,7 +345,7 @@ export function resolveFlowPrecedence( const owner = loaderSetOwner(packagedFlowOwner, name); const group = members.map((member) => ({ ...member, - contender: classifyContender(member.definition, owner), + contender: classifyContender(member.definition, owner, installed), })); const ranked = [...group].sort((a, b) => { const byRank = precedenceRank(a.contender) - precedenceRank(b.contender); @@ -380,7 +389,7 @@ export function resolveFlowPrecedence( ? ' by package id' : ' by arrival order'; const remedy = sealedOverStored - ? undeclaredRemedy(owner) + ? undeclaredRemedy(owner, installed) : 'Rename one of them.'; logger?.warn( `[Automation] Flow name collision: '${name}' is claimed by ${group.length} definitions ` + diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index c03b93fbf75..1d4f72bd940 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { effectiveTenancyPosture, isServiceNotRegisteredError, resolveLocalizationContext, resolveUserAuthzGrants } from '@objectstack/core'; import type { IClusterService, IJobService, IPubSub } from '@objectstack/spec/contracts'; +import { installedManifestsOf, type InstalledPackageSource } from '@objectstack/metadata-core'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import type { Connector, @@ -580,6 +581,23 @@ export function nonHostDeclaringPackageOf( return declaresSameProviderBinding(entry, declaration, stamp) ? undefined : { packageId: stamp, why: 'stored' }; } +/** + * The package registry ObjectQL serves, read where it is used, or `undefined` + * when the host serves no ObjectQL: the installed manifests the customization + * pair lookup reads (`installedManifestsOf`). Without one, no package is + * installed, and every package is its own App. Absent is read the way this + * plugin's other optional `objectql` reads it (`flowHistoryReader`, the + * suspended-run store): a `getService` that throws is no service, whichever + * kernel spelled the refusal. + */ +function objectqlRegistryOf(ctx: PluginContext): InstalledPackageSource | undefined { + try { + return ctx.getService<{ registry?: InstalledPackageSource }>('objectql')?.registry; + } catch { + return undefined; + } +} + /** * [#28713] Whether the kernel this plugin runs on dispatches its boot-phase * hooks (`kernel:bootstrapped` among them) once every plugin has started — @@ -5224,6 +5242,7 @@ export class AutomationServicePlugin implements Plugin { items, ctx.logger, engine ? (name) => engine.packagedFlowOwner(name) : undefined, + installedManifestsOf(objectqlRegistryOf(ctx)), ); } diff --git a/packages/services/service-datasource/src/plugin.ts b/packages/services/service-datasource/src/plugin.ts index ad057ac0d10..2c6493e4eb2 100644 --- a/packages/services/service-datasource/src/plugin.ts +++ b/packages/services/service-datasource/src/plugin.ts @@ -8,7 +8,7 @@ import type { MetadataProtocol, } from '@objectstack/spec/api'; import { customizationNamespace } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOf, pairOf } from '@objectstack/metadata-core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import type { IDataEngine, IObjectQLEngine, IntrospectedSchema } from '@objectstack/spec/contracts'; import { @@ -220,11 +220,13 @@ export class ExternalDatasourceServicePlugin implements Plugin { } }; + /** The engine registry `installPackage` stores package records in, read where it is used. */ + const objectqlRegistry = (): { getPackage?: (id: string) => { manifest?: unknown } | undefined } | undefined => + safeGetService<{ registry?: { getPackage?: (id: string) => { manifest?: unknown } | undefined } }>(ctx, 'objectql')?.registry; + /** A package record as `installPackage` stored it in the engine registry, read where it is used. */ const packageRecord = (packageId: string): { manifest?: { namespace?: unknown } } | undefined => - safeGetService<{ registry?: { getPackage?: (id: string) => unknown } }>(ctx, 'objectql')?.registry?.getPackage?.( - packageId, - ) as { manifest?: { namespace?: unknown } } | undefined; + objectqlRegistry()?.getPackage?.(packageId) as { manifest?: { namespace?: unknown } } | undefined; const namespaceOf = (record: { manifest?: { namespace?: unknown } } | undefined): string | undefined => { const ns = record?.manifest?.namespace; @@ -370,7 +372,7 @@ export class ExternalDatasourceServicePlugin implements Plugin { if (doors.isWritablePackage(basePackageId)) { return { packageId: basePackageId, namespace: namespaceOf(base) }; } - const pairId = pairOf(basePackageId); + const pairId = pairOf(basePackageId, installedManifestsOf(objectqlRegistry())); if (!pairId.ok) { throw importLandingRefusal( `Datasource '${datasource}' is declared by read-only package '${basePackageId}', which has no `