diff --git a/.changeset/28574-object-extension-package-item.md b/.changeset/28574-object-extension-package-item.md new file mode 100644 index 00000000000..a2cce6d5cad --- /dev/null +++ b/.changeset/28574-object-extension-package-item.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/objectql": minor +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +"@objectstack/platform-objects": minor +--- + +feat(metadata-protocol,objectql): an object extension is an item of a writable package — saved, published and exported through the existing doors + +Clause-②: yes (widening) +Metadata-protocol: added + +A package can now add fields and rules to an object another package defines by saving an `object_extension` item through `/api/v1/meta`, the same way it saves any other item. This is what Studio's add-field on a base object writes (ADR-0146 D10): an extension in the base's paired `.custom` package, its fields suffixed `__c`, the base untouched. + +- **The type** (`@objectstack/spec/kernel`). `object_extension` joins `MetadataTypeSchema` and the metadata type registry. Its body is `ObjectExtensionSchema` as it stands: it carries `extend` and no `name`. The row is filed under the target object's name, one extension per package per target. `OVERRIDE_TYPE_CLASSIFICATION` lists it as `excluded`: an extension adds to its target, it never replaces a base item, so it never carries `overrides`. That disposition does not close the type to a `.custom` pair. +- **The doors.** Draft, publish, history restore and delete are the doors every item uses. The save, publish and restore doors refuse, with `VALIDATION_ERROR` / 400 and an issue path: a write bound to no package; `defaultRowScope` or `membershipKeys` in the body (the registry's own sentences); a row filed under a name other than its `extend`; an unregistered target; a target outside the package's dependency closure (with the closure prescription the other save doors print); and a `.custom` field without the `__c` suffix (the existing `__c` judge). A body the schema refuses answers 422 `INVALID_METADATA` with the schema's issues. No error code is added. +- **The registry** (`@objectstack/objectql`). A published row registers its whole parsed body as the package's `'extend'` contributor (`SchemaRegistry.registerObjectExtension`), so its fields, validations and indexes appear on every read of the object, and a publish adds the column to the target's table. Every stored-row sync re-registers it (boot, write-through, read hydration), and deleting the row or uninstalling the package withdraws it (`SchemaRegistry.withdrawObjectExtension`). What a manifest's `objectExtensions` register is unchanged. +- **The export** (`@objectstack/runtime`). `GET /packages/:id/export` carries each of the package's extension items as an `objectExtensions` entry that re-installs. A package's `objects` are now only the objects it owns: an object the package extends is no longer exported as an `'own'` object, which re-install refused with `ObjectOwnershipConflictError`. +- **`@objectstack/platform-objects`**: the metadata-forms translation bundles gain the type's `label` and `description`, authored in zh-CN, ja-JP and es-ES. +- **`@objectstack/lint/runtime`** exports `closureDependencyRemedy`, the closure prescription the save doors print. + +Two costs, both on purpose: `/meta/types` serves no JSON schema for `object_extension`, and a draft carries no `_diagnostics` badge, because the type is not bound in `BUILTIN_METADATA_TYPE_SCHEMAS` (that map requires the ADR-0010 envelope, which would change the schema a manifest's `objectExtensions` parse with). diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 74a98a27656..ac8a8ce6ad0 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -642,7 +642,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -709,7 +709,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| … +18 more>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | @@ -717,6 +717,7 @@ Metadata query with filtering, sorting, and pagination ### Allowed Values: `MetadataRegisterRequest.type` * `object` +* `object_extension` * `field` * `hook` * `seed` diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index df076a9388e..80cdf91625c 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -226,6 +226,7 @@ const result = MetadataBulkResultSchema.parse(data); ### Allowed Values * `object` +* `object_extension` * `field` * `hook` * `seed` @@ -266,7 +267,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| … +18 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -283,6 +284,7 @@ const result = MetadataBulkResultSchema.parse(data); ### Allowed Values: `MetadataTypeRegistryEntry.type` * `object` +* `object_extension` * `field` * `hook` * `seed` diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 88f6e710d0e..83af3d8c26e 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -57,6 +57,15 @@ export const KIND_COVERAGE: Record = { notes: 'Also carries the validation-rule surface: rules are authored inline via object `validations` (the `validation` KIND was retired in #4509 — ADR-0088 — because a standalone rule had no way to name the object it validated). Every declared rule type is write-path enforced (rule-validator dispatches all of state_machine/script/cross_field/format/json_schema/conditional — ADR-0020 "no silent no-ops", closing the #1475 gap) and each is demonstrated: state_machine (task/project), script+cross_field (project), format/json_schema/conditional (account). Field-level requiredWhen/readonlyWhen are likewise enforced and demonstrated on invoice.', }, + // [#28574] A registry kind since an extension became an authorable item of a + // writable package. The showcase's own extension is the code half (the stack's + // `objectExtensions`, also tracked in STACK_COLLECTION_COVERAGE); the runtime + // half is `PUT /api/v1/meta/object_extension/?package=`. + object_extension: { + status: 'demonstrated', + files: ['src/data/extensions/account.extension.ts'], + notes: 'Authored in code as an `objectExtensions` entry; the same body is an `object_extension` item when a writable package saves it through `/api/v1/meta`.', + }, field: { status: 'demonstrated', files: ['src/data/objects/field-zoo.object.ts'], diff --git a/packages/lint/src/runtime.ts b/packages/lint/src/runtime.ts index 60f6167acd3..f95baff92b0 100644 --- a/packages/lint/src/runtime.ts +++ b/packages/lint/src/runtime.ts @@ -70,6 +70,11 @@ export { runtimeGatedTypes, stackKeyForType, } from './runtime-gate.js'; +// [#28574] The closure prescription the save doors print, for a door outside +// this package that judges an object name under a package closure (the +// `object_extension` item gate in `@objectstack/metadata-protocol`), so the +// sentence is not spelled a second time. +export { closureDependencyRemedy } from './object-graph.js'; export type { RuntimeGateResult, RuntimePackageScope, diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts index b4956ed6b55..771bca034f8 100644 --- a/packages/metadata-protocol/src/protocol-authoring-gate.ts +++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts @@ -79,6 +79,7 @@ export interface AuthoringGateHost { objectNamespacePrefixViolation: ObjectStackProtocolImplementation['objectNamespacePrefixViolation']; hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted']; isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked']; + objectExtensionItemRefusal: ObjectStackProtocolImplementation['objectExtensionItemRefusal']; orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced']; permissionSetNameCollisionRefusal: ObjectStackProtocolImplementation['permissionSetNameCollisionRefusal']; refuseRestoredBodyTheSaveDoorRefuses: ObjectStackProtocolImplementation['refuseRestoredBodyTheSaveDoorRefuses']; @@ -101,7 +102,7 @@ export function restoredBodyDerivation( /** The body of `ObjectStackProtocolImplementation#restoredBodyWriter`; the method's docblock there is its contract. */ export function restoredBodyWriter( - this: Pick, + this: Pick, type: string, name: string, binding: string | null, source: string): (historyBody: unknown) => Promise { const derive = this.restoredBodyDerivation(type); return async (historyBody) => { @@ -112,6 +113,9 @@ export function restoredBodyWriter( // [ADR-0146 D10, #28564] The `__c` reservation, on the same binding. const suffixRefusal = objectBodyExtensionFieldSuffixRefusal(type, historyBody, name, binding); if (suffixRefusal) throw suffixRefusal; + // [#28574] An `object_extension` version against its address, on the same binding. + const extensionRefusal = this.objectExtensionItemRefusal(type, historyBody, name, binding); + if (extensionRefusal) throw extensionRefusal; await this.refuseRestoredBodyTheSaveDoorRefuses(type, name, historyBody, binding, source); return derive ? derive(historyBody) : historyBody; }; diff --git a/packages/metadata-protocol/src/protocol-extension-field-suffix.ts b/packages/metadata-protocol/src/protocol-extension-field-suffix.ts index 8a26f552bf2..4502b428cd9 100644 --- a/packages/metadata-protocol/src/protocol-extension-field-suffix.ts +++ b/packages/metadata-protocol/src/protocol-extension-field-suffix.ts @@ -13,8 +13,14 @@ * protocol` (`saveMetaItem`, `rollbackMetaItem`, and the promotion * `publishMetaItem` / `publishPackageDrafts` share), for an `object` body, * through {@link objectBodyExtensionFieldSuffixRefusal}; + * - the same three doors for an `object_extension` item, as the package's one + * `objectExtensions` entry, through {@link judgeExtensionFieldSuffix} + * (`objectExtensionItemRefusal`, `protocol-object-extension-item.ts`); * - `duplicatePackage`, for every object the copy would write, judged against * the TARGET id before the copy mints anything; + * - `reassignOrphanedMetadata` (the adopt-orphans door), for every active + * `object` row the adoption would rebind, judged against the TARGET id + * before the first storage update; * - `os validate` (`@objectstack/cli`) and the marketplace install * (`@objectstack/cloud-connection`), for a whole package body, through * {@link judgeExtensionFieldSuffix}. diff --git a/packages/metadata-protocol/src/protocol-object-extension-item.ts b/packages/metadata-protocol/src/protocol-object-extension-item.ts new file mode 100644 index 00000000000..796dce22cb1 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-object-extension-item.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28574, ADR-0146 D10 / D2 / D3] An object extension is an authorable item + * of a writable package. + * + * `object_extension` is the type the artifact loader already stores a code + * package's `objectExtensions` under; since #28574 it is also a row a running + * deployment writes. Studio's add-field on a base object (N-23c) writes + * exactly this: an extension in the base's paired `.custom`, its fields + * suffixed `__c`, the base untouched. The row is stored under the writing + * package with the TARGET object's name as its row name — one extension per + * package per target — and its body is `ObjectExtensionSchema` as it stands: + * it carries `extend` and no `name`. + * + * Draft, publish, history, rollback and delete are the doors every item uses. + * This module holds what those doors ask that is particular to the type, so + * no door spells it twice: + * + * - {@link objectExtensionItemRefusal} — the save / publish / restore gate, + * asked beside the `__c` judge it calls; + * - {@link registrableObjectExtension} — the whole parsed body every sync + * registers as the package's `'extend'` contributor + * (`SchemaRegistry.registerObjectExtension`). + * + * The body's SHAPE is judged by the doors' own schema gate + * (`resolveOverlaySchema` → {@link OBJECT_EXTENSION_ITEM_SCHEMA}), with the + * envelope every other type's 422 carries. What the gate here adds is the + * body against its address, `VALIDATION_ERROR` / 400 — the envelope of the + * doors' other body-against-address refusals (`savedItemNameRefusal`, + * `permissionSetOwnerBindingRefusal`, the `__c` judge). No code is minted. + */ + +import { z } from 'zod'; +import { ObjectExtensionSchema } from '@objectstack/spec/data'; +import { MetadataProtectionFields } from '@objectstack/spec/kernel'; +import { closureDependencyRemedy, narrowObjectsToPackageClosure } from '@objectstack/lint/runtime'; +import type { RuntimePackageScope } from '@objectstack/lint/runtime'; +import { judgeExtensionFieldSuffix, extensionFieldSuffixRefusalError } from './protocol-extension-field-suffix.js'; +import type { MetadataIssueEntry } from './protocol.js'; + +/** The metadata type of an object extension item. */ +export const OBJECT_EXTENSION_TYPE = 'object_extension' as const; + +/** The ADR-0010 envelope keys, read off the spec's own mixin. */ +const PROTECTION_ENVELOPE_KEYS: readonly string[] = Object.keys(MetadataProtectionFields); + +const isRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +/** + * `body` without the ADR-0010 envelope a loader or a read stamps on it + * (`_packageId`, `_provenance`, `_lock`, …): the keys the strict + * `ObjectExtensionSchema` does not declare and an author never writes. + * Returns `body` itself when it carries none. + */ +export function withoutProtectionEnvelope(body: unknown): unknown { + if (!isRecord(body) || !PROTECTION_ENVELOPE_KEYS.some((k) => k in body)) return body; + const out = { ...body }; + for (const k of PROTECTION_ENVELOPE_KEYS) delete out[k]; + return out; +} + +/** + * The schema the doors' shape gate judges an `object_extension` body by: + * `ObjectExtensionSchema` itself, strict, over the body with its envelope + * peeled off. Not bound in `BUILTIN_METADATA_TYPE_SCHEMAS` (the spec ruling on + * #28574): the ADR-0010 envelope that map requires would change the schema a + * manifest's `objectExtensions` parse with, so the envelope a read or a loader + * stamps is taken off before the parse instead. + */ +export const OBJECT_EXTENSION_ITEM_SCHEMA = z.preprocess(withoutProtectionEnvelope, ObjectExtensionSchema); + +/** + * The name a list read slots an item under. An item's own `name`; for an + * `object_extension` body, which carries `extend` and no `name` + * (`ObjectExtensionSchema` refuses one), its target — the name its row is + * stored under (one extension per package per target). The body is not + * given a `name`: a read serves it as it is stored, so what a GET answers + * PUTs back through the strict schema. `undefined` = not addressable. + */ +export function listSlotNameOf(canonicalType: string, item: unknown): unknown { + if (!isRecord(item)) return undefined; + if ('name' in item) return item.name; + if (canonicalType === OBJECT_EXTENSION_TYPE && typeof item.extend === 'string') return item.extend; + return undefined; +} + +/** The registry members the gate and the syncs read, reached through the engine. */ +export interface ObjectExtensionRegistry { + getObject?(name: string): unknown; + extendContributionRefusal?( + schema: { name?: unknown; defaultRowScope?: unknown; membershipKeys?: unknown }, + packageId: string | undefined, + ): Error | undefined; + registerObjectExtension?(extension: { extend: string; priority?: number } & Record, packageId: string): string; + withdrawObjectExtension?(name: string, packageId: string): boolean; + getObjectContributors?(name: string): Array<{ packageId: string; ownership: string; definition?: unknown }>; +} + +type GateRefusal = Error & { code: 'VALIDATION_ERROR'; status: 400; issues?: MetadataIssueEntry[] }; + +function refusal(message: string, path?: string): GateRefusal { + const err = new Error(message) as GateRefusal; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + if (path !== undefined) err.issues = [{ path, message, code: 'custom' }]; + return err; +} + +/** What a door hands the gate: the item, the row name, and the package the row is written into. */ +export interface ObjectExtensionItemSubject { + /** The body as the door will store it. */ + item: unknown; + /** The row name — the extension's target object. */ + name: string; + /** The RESOLVED package the row is written into (the door's `saveWriteBinding`). */ + binding: string | null | undefined; + /** The engine's registry. Absent ⇒ the target checks are not asked. */ + registry: ObjectExtensionRegistry | undefined; + /** + * The written package's closure (`resolveWritePackageScope`). `undefined` + * when it cannot be read ⇒ the closure is not narrowed, the gate's + * fail-open direction everywhere else (`narrowObjectsToPackageClosure`). + */ + scope: RuntimePackageScope | undefined; +} + +/** + * The save, publish and restore doors' question for one `object_extension` + * body, in order. `undefined` = admitted (or not this type). A body whose + * `extend` is not a string is left to the schema gate, which refuses it with + * the schema's own sentence (`extend` is required, and its aliases name it). + * + * 1. The row is a package's item: a write bound to no package is refused. + * 2. The registry's own `extend` refusals (`defaultRowScope`, + * `membershipKeys`) with their sentences — asked BEFORE the strict parse + * would refuse the key with the generic unknown-key sentence, so the + * author reads the reason a sync would otherwise throw at registration. + * 3. The row name is the target: `extend` equals the name it is filed under. + * 4. The target object is registered. + * 5. The target lies inside the written package's dependency closure — the + * package's own objects, its declared dependencies' and platform objects + * (`narrowObjectsToPackageClosure`, the one predicate), refused with the + * closure prescription every save door prints (`closureDependencyRemedy`). + * 6. The `__c` reservation (`judgeExtensionFieldSuffix`, #28564), judged as + * the package's `objectExtensions` entry; an extension of the package's + * own object adds to its own fields, as the judge reads it. + * + * Whether the package is writable is the door's own refusal + * (`WRITABLE_PACKAGE_REQUIRED`), asked before any of this. + */ +export function objectExtensionItemRefusal( + singularType: string, + subject: ObjectExtensionItemSubject, +): Error | undefined { + if (singularType !== OBJECT_EXTENSION_TYPE) return undefined; + const { item, name, binding, registry, scope } = subject; + if (!isRecord(item)) return undefined; + + // 1. + if (typeof binding !== 'string' || binding === '') { + return refusal( + `Object extension '${name}': an object extension is an item of a package, and this write names none. ` + + 'Save it into the writable package it belongs to (`?package=`): for a base\'s object, the base\'s ' + + 'paired `.custom` package.', + ); + } + + // 2. + const target = typeof item.extend === 'string' ? item.extend : undefined; + const registryRefusal = registry?.extendContributionRefusal?.({ ...item, name: target ?? name }, binding); + if (registryRefusal) { + const key = item.defaultRowScope !== undefined ? 'defaultRowScope' : 'membershipKeys'; + return refusal(registryRefusal.message, key); + } + if (target === undefined) return undefined; + + // 3. + if (target !== name) { + return refusal( + `Object extension '${name}' extends '${target}': an object extension is stored under the name of the ` + + `object it extends. Save it as '${target}', or set \`extend\` to '${name}'.`, + 'extend', + ); + } + + // 4. and 5. + if (registry && typeof registry.getObject === 'function') { + const object = registry.getObject(target); + if (!isRecord(object)) { + return refusal( + `Object extension '${name}': no object named '${target}' is registered, so there is nothing to ` + + 'extend. Name an installed object in `extend`.', + 'extend', + ); + } + if (narrowObjectsToPackageClosure([object], scope).length === 0) { + return refusal( + `Object extension '${name}' in package '${binding}': object '${target}' is outside the package's ` + + `dependency closure. ${closureDependencyRemedy(target, binding, 'extend an object the package can reach')}.`, + 'extend', + ); + } + } + + // 6. + const owner = (registry?.getObject?.(target) as { _packageId?: unknown } | undefined)?._packageId; + const suffix = judgeExtensionFieldSuffix({ + packageId: binding, + objects: owner === binding ? { [target]: { name: target } } : undefined, + objectExtensions: [item], + }).map((r) => ({ ...r, path: r.path.slice('objectExtensions[0].'.length) })); + if (suffix.length > 0) return extensionFieldSuffixRefusalError(suffix); + return undefined; +} + +/** + * The body a sync registers for a stored `object_extension` row: the WHOLE + * parsed body (`ObjectExtensionSchema`: its defaults, such as `priority`, and + * its rewrites applied), with the envelope peeled off first and the row's + * tenant provenance stated (ADR-0010 `_provenance: 'org'`, as every stored + * `object` row registers). Throws the parse's error for a body the schema + * refuses, so each sync's own per-row handling names it. + */ +export function registrableObjectExtension( + body: unknown, +): { extend: string; priority?: number } & Record { + const parsed = OBJECT_EXTENSION_ITEM_SCHEMA.safeParse(body); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const err = new Error( + `stored object extension does not parse as ObjectExtensionSchema` + + (first ? ` (${first.path.join('.') || ''}: ${first.message})` : ''), + ) as Error & { code: string; status: number }; + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } + const registrable = parsed.data as { extend: string; priority?: number } & Record; + return { ...registrable, _provenance: 'org' }; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3a2f380e0b0..b6c60a41b31 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -423,6 +423,15 @@ import { classifyObjectContribution, releaseAdoptedObjectOwner } from './protoco import { flowSaveReplaysRetired } from './protocol-flow-save-replay.js'; // [#25467 cut 19] A package write's validation scope: the written package and its declared dependency closure, from the registry. import { resolveWritePackageScope } from './protocol-write-package-scope.js'; +// [#28574, ADR-0146 D10] An object extension as an authorable item: the doors' gate, its shape schema, and the body every sync registers. +import { + OBJECT_EXTENSION_ITEM_SCHEMA, + OBJECT_EXTENSION_TYPE, + objectExtensionItemRefusal, + listSlotNameOf, + registrableObjectExtension, +} from './protocol-object-extension-item.js'; +import type { ObjectExtensionRegistry } from './protocol-object-extension-item.js'; // [#25467 cut 20] The code-layer read through the `metadata` service: package-scoped, singular/plural fallback, a stored object row's copy not counted as code. import { readItemFromMetadataService } from './protocol-metadata-service-read.js'; // [#25467 cut 21] The boot report of the stored overlay rows no read serves because they overlay sealed managed content. @@ -1064,6 +1073,10 @@ const HAND_CRAFTED_SCHEMAS: Record> = { */ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null { const singular = PLURAL_TO_SINGULAR[type] ?? type; + // [#28574] `object_extension` has no `BUILTIN_METADATA_TYPE_SCHEMAS` entry + // (the spec ruling on the card), so its schema is bound here, the door's + // own resolver: `ObjectExtensionSchema` over the body without its envelope. + if (singular === OBJECT_EXTENSION_TYPE) return OBJECT_EXTENSION_ITEM_SCHEMA as unknown as z.ZodTypeAny; return getMetadataTypeSchema(singular) ?? null; } @@ -3141,20 +3154,25 @@ export function mergePackageAwareOverlay( return undefined; }; + // [#28574] The name an item is slotted under: its own `name`, or an + // `object_extension`'s target ({@link listSlotNameOf}). + const canonicalType = canonicalMetaUrlType(type); for (const raw of baseItems) { const item = raw as any; - if (item && typeof item === 'object' && 'name' in item) { - push(slotOf(item, item.name), (item._packageId ?? undefined) as string | undefined, item, + const itemName = listSlotNameOf(canonicalType, item); + if (itemName !== undefined) { + push(slotOf(item, itemName), (item._packageId ?? undefined) as string | undefined, item, undefined, unseated?.has(item)); } } for (const { data, packageId, stored, standIn } of records) { const body = data as any; - if (!(body && typeof body === 'object' && 'name' in body)) continue; + const bodyName = listSlotNameOf(canonicalType, body); + if (bodyName === undefined) continue; // The base row this record shadows at its own slot (for view-identity // healing): a same-package row, else a package-less one, else any // same-slot row it stands in for. - const slot = slotOf(body, body.name); + const slot = slotOf(body, bodyName); const list = buckets.get(slot); const prev = list ? (list.find((c) => c.pkg === packageId)?.item @@ -7356,6 +7374,7 @@ export class ObjectStackProtocolImplementation implements private restoredBodyWriter(type: string, name: string, binding: string | null, source: string): (historyBody: unknown) => Promise { // [#25467 cut 8] The body moved to `protocol-authoring-gate.ts` (see `AuthoringGateHost`). const host: ThisParameterType = { + objectExtensionItemRefusal: (...a) => this.objectExtensionItemRefusal(...a), refuseRestoredBodyTheSaveDoorRefuses: (...a) => this.refuseRestoredBodyTheSaveDoorRefuses(...a), restoredBodyDerivation: (...a) => this.restoredBodyDerivation(...a), }; @@ -7803,6 +7822,99 @@ export class ObjectStackProtocolImplementation implements return resolveWritePackageScope.call(host, packageId); } + /** + * [#28574, ADR-0146 D10] The save, publish and restore doors' gate for an + * `object_extension` body written into `binding` (the RESOLVED package of + * the row): {@link objectExtensionItemRefusal}, asked with this engine's + * registry and the package's dependency closure + * ({@link resolveWritePackageScope}). Any other type answers `undefined`. + */ + private objectExtensionItemRefusal( + type: string, + item: unknown, + name: string, + binding: string | null | undefined, + ): Error | undefined { + if ((PLURAL_TO_SINGULAR[type] ?? type) !== OBJECT_EXTENSION_TYPE) return undefined; + return objectExtensionItemRefusal(OBJECT_EXTENSION_TYPE, { + item, + name, + binding, + registry: this.engine?.registry as ObjectExtensionRegistry | undefined, + scope: this.resolveWritePackageScope(binding), + }); + } + + /** + * [#28574, ADR-0146 D10] Register a stored `object_extension` row as its + * package's `'extend'` contributor of the object it names — the WHOLE + * parsed body ({@link registrableObjectExtension}), through the registry's + * own verb (`SchemaRegistry.registerObjectExtension`). The one call every + * sync makes: the write-through ({@link applyRegistryWriteThrough}), the + * boot hydration ({@link loadMetaFromDb}) and the read-side and heal + * hydrations ({@link hydrateOverlayIntoRegistry}). A row bound to no + * package is never registered: the doors refuse to write one, and an + * extension needs a contributor id. + * + * Throws what the parse or the registry refuses, so each caller's own + * per-row handling names the row. + * + * @returns whether the row was registered. + */ + private registerStoredObjectExtension(body: unknown, packageId: string | null | undefined): boolean { + if (typeof packageId !== 'string' || packageId === '') return false; + const registry = this.engine?.registry as ObjectExtensionRegistry | undefined; + if (typeof registry?.registerObjectExtension !== 'function') return false; + registry.registerObjectExtension(registrableObjectExtension(body), packageId); + return true; + } + + /** + * [#28574] After an `object_extension` row of `name` is deleted: withdraw + * every stored-row extension of the object whose row is no longer stored + * (`SchemaRegistry.withdrawObjectExtension`), and re-register the ones that + * are. A stored row's contribution is the tenant-authored one + * (`_provenance: 'org'`, {@link registrableObjectExtension}); a code + * package's manifest extension of the same object is never touched. + * + * An outage is not an absence: a failed read withdraws nothing. + */ + private async resyncStoredObjectExtensions(name: string): Promise { + const registry = this.engine?.registry as ObjectExtensionRegistry | undefined; + if (typeof registry?.getObjectContributors !== 'function' || typeof registry.withdrawObjectExtension !== 'function') return; + let rows: Array> | null | undefined; + try { + rows = await this.findEnvironmentRows('sys_metadata', { + where: { type: OBJECT_EXTENSION_TYPE, name, state: 'active' }, + context: { isSystem: true }, + }); + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const stored = new Map>(); + for (const row of rows ?? []) { + const pkg = boundPackageOf(row.package_id); + if (pkg) stored.set(pkg, row); + } + for (const contributor of registry.getObjectContributors(name)) { + if (contributor.ownership !== 'extend') continue; + if ((contributor.definition as { _provenance?: unknown } | undefined)?._provenance !== 'org') continue; + if (!stored.has(contributor.packageId)) registry.withdrawObjectExtension(name, contributor.packageId); + } + for (const [pkg, row] of stored) { + try { + this.registerStoredObjectExtension( + this.convertStoredItem(String(row.type ?? OBJECT_EXTENSION_TYPE), storedRowDocument(row)), + pkg, + ); + } catch (err: any) { + console.warn( + `[Protocol] object extension '${name}' of package '${pkg}' could not be re-registered: ${err?.message ?? err}`, + ); + } + } + } + /** * [#6285] Does this deployment enforce an organization wall (ADR-0105 D1)? * @@ -18771,6 +18883,14 @@ export class ObjectStackProtocolImplementation implements // [#6602] ADR-0005 — a per-org overlay is served on demand, never // grafted into the registry every org in this process shares. if (options.organizationId !== null && options.organizationId !== undefined) return false; + // [#28574] An `object_extension` row registers as its package's + // `'extend'` contributor of the object it names, never as an item: + // the body carries `extend`, not `name`. The boot hydration + // ({@link loadMetaFromDb}) and the read-side hydration reach it here. + // See {@link registerStoredObjectExtension}. + if (canonicalType === OBJECT_EXTENSION_TYPE) { + return this.registerStoredObjectExtension(data, options.packageId); + } if (!data || typeof data !== 'object' || !('name' in data)) return false; const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; @@ -19565,6 +19685,21 @@ export class ObjectStackProtocolImplementation implements await this.applyObjectRegistryMutation(request); return; } + // [#28574] An `object_extension` row is its package's `'extend'` + // contributor of the object it names: not org-gated and not + // kernel-scope-gated, for the `object` branch's own reason — it adds + // columns to an env-wide table and fields to every read of it. Best-effort + // like every branch here: the row is already persisted. + if (request.type === OBJECT_EXTENSION_TYPE) { + try { + this.registerStoredObjectExtension(request.item, request.packageId); + } catch (err: any) { + console.warn( + `[Protocol] registry write-through failed for ${request.type}/${request.name}: ${err?.message ?? err}`, + ); + } + return; + } // [#23298] Whose registry this is — never `environmentId` alone. if (!this.registryTakesStoredRows()) return; try { @@ -19752,6 +19887,13 @@ export class ObjectStackProtocolImplementation implements // by this same walk. See the header. Ahead of the container's own // walk, which returns early from three of its tiers. await this.reconcileViewExpansions(type, { container: name, removed: 'every-row' }, organizationId); + // [#28574] An `object_extension` has no shadow, baseline or overlay + // entry to heal: its rows are `'extend'` contributors. The stored rows + // that remain say which stay. See {@link resyncStoredObjectExtensions}. + if ((PLURAL_TO_SINGULAR[type] ?? type) === OBJECT_EXTENSION_TYPE) { + await this.resyncStoredObjectExtensions(name); + return; + } try { const registry: any = this.engine.registry; const singular = PLURAL_TO_SINGULAR[type] ?? type; @@ -20026,6 +20168,11 @@ export class ObjectStackProtocolImplementation implements // hazard because `listDrafts` returns the draft row's stored type // verbatim). Guard-only either way: `type` never reaches // `syncObjectSchema`, which takes `name` alone. + // + // [#28574] An `object_extension` row is named by its target, so the + // same sync gives the target's table the columns the extension adds — + // the DDL path a manifest extension takes at boot, unchanged. + if (type === OBJECT_EXTENSION_TYPE) return this.ensureObjectStorage('object', name); if (type !== 'object') return; try { await this.engine.syncObjectSchema(name); @@ -21868,6 +22015,19 @@ export class ObjectStackProtocolImplementation implements ); if (suffixRefusal) throw suffixRefusal; } + // [#28574, ADR-0146 D10] …and an `object_extension` body against + // its address, on the same binding and with the same envelope: a + // package's item, its row name its target, the target registered + // and inside the package's closure, the registry's own `extend` + // refusals and the `__c` reservation. Draft and publish alike. See + // {@link objectExtensionItemRefusal}. + { + const extensionRefusal = this.objectExtensionItemRefusal( + singularType, request.item, request.name, + await this.saveWriteBinding(singularType, request, mode), + ); + if (extensionRefusal) throw extensionRefusal; + } // [harden] …and a permission set whose NAME another owner already // holds, with `NAMESPACE_CONFLICT` / 422: a set's name is its // identity across the deployment, and a grant resolves it by that @@ -23997,6 +24157,16 @@ export class ObjectStackProtocolImplementation implements ); if (suffixRefusal) throw suffixRefusal; } + // [#28574] …and an `object_extension` draft against its address, judged + // against the package promoted into: the target may have left the + // registry or the closure since the draft was saved. See + // {@link objectExtensionItemRefusal}. + if (draftForGate) { + const extensionRefusal = this.objectExtensionItemRefusal( + singularType, draftForGate.body, request.name, promoteInto, + ); + if (extensionRefusal) throw extensionRefusal; + } // [harden] …and a permission-set draft whose name a DIFFERENT owner // already holds by the time it is published: a draft save judged the // holders of its moment, and another owner may have taken that name diff --git a/packages/metadata-protocol/src/reference-sites.ts b/packages/metadata-protocol/src/reference-sites.ts index 1284c42323a..cd45e401938 100644 --- a/packages/metadata-protocol/src/reference-sites.ts +++ b/packages/metadata-protocol/src/reference-sites.ts @@ -111,6 +111,7 @@ import { z } from 'zod'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel'; import { SCHEMALESS_NODE_CONFIG_SCHEMAS } from '@objectstack/spec/automation'; +import { ObjectExtensionSchema } from '@objectstack/spec/data'; /** * One derived fact: an item of `fromType` may name a `target` item through a @@ -283,9 +284,21 @@ function nameVocabularyRejectsKey(type: DeclaredMetadataType, separator: string) return verdict === 'unreadable' || verdict === 'rejects'; } +/** + * A declared type's Zod schema: {@link getMetadataTypeSchema}'s, and for + * `object_extension` (#28574) `ObjectExtensionSchema`, the schema its doors + * judge it by. That type is deliberately not bound in + * `BUILTIN_METADATA_TYPE_SCHEMAS` (the map requires the ADR-0010 envelope, + * which would change the schema a manifest's `objectExtensions` parse with), + * yet what an extension references is readable off its own schema. + */ +function declaredTypeSchema(type: string): z.ZodType | undefined { + return getMetadataTypeSchema(type) ?? (type === 'object_extension' ? ObjectExtensionSchema : undefined); +} + /** A declared type's JSON Schema, or `undefined` when it resolves no schema or cannot be converted. */ function metadataTypeJsonSchema(type: string): JsonSchemaNode | undefined { - const schema = getMetadataTypeSchema(type); + const schema = declaredTypeSchema(type); if (!schema) return undefined; try { return z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode; @@ -519,9 +532,16 @@ function collectSitesFromSchema( singular: ReadonlyMap, plural: ReadonlyMap, into: Map, + sharedWalks?: SharedWalkCache, ): void { const defs = (root.$defs as Record | undefined) ?? {}; const visited = new Set(); + // Lazily, and once per root: the `$defs` a property's `$ref`s resolve + // through are part of what the property means. + let defsKey: string | undefined; + // The bindings found below the root property being walked, so its walk + // can be replayed under another source type. + let frame: Array | undefined; const walk = (node: unknown, trail: string, depth: number): void => { if (depth > 12) return; @@ -535,11 +555,13 @@ function collectSitesFromSchema( if (target && carriesNames(child, defs, pluralTarget !== undefined)) { const site: ReferenceSite = { fromType, property: key, target }; into.set(`${fromType}|${key}|${target}`, site); + frame?.push([key, target]); } const branch = `${trail}.${key}`; if (visited.has(branch)) continue; visited.add(branch); - walk(child, branch, depth + 1); + if (depth === 0) walkRootProperty(child, branch); + else walk(child, branch, depth + 1); } } if (alt.items) walk(alt.items, `${trail}[]`, depth + 1); @@ -549,9 +571,55 @@ function collectSitesFromSchema( } }; + // [#28574] A root property's walk, done once per derivation for every + // source type that declares the same property. `object` and + // `object_extension` declare the same `fields` record and `validations` + // array, and the `validations` walk alone is most of a type's cost; the + // second source type replays the bindings the first walk found. The key + // is everything the walk below the property reads: the property's schema + // less its prose (`description` and `title` text, which no step reads and + // which is all that tells the two `validations` apart), its trail and the + // root's `$defs`. No branch below a root property's trail is visited + // before its walk starts (every such branch extends it), so a replay + // records exactly what a walk would. + const walkRootProperty = (child: unknown, branch: string): void => { + if (!sharedWalks) { + walk(child, branch, 1); + return; + } + defsKey ??= JSON.stringify(defs, withoutProse); + const cacheKey = `${branch}|${defsKey}|${JSON.stringify(child, withoutProse)}`; + const replay = sharedWalks.get(cacheKey); + if (replay) { + for (const [key, target] of replay) { + into.set(`${fromType}|${key}|${target}`, { fromType, property: key, target }); + } + return; + } + frame = []; + try { + walk(child, branch, 1); + sharedWalks.set(cacheKey, frame); + } finally { + frame = undefined; + } + }; + walk(root, '', 0); } +/** + * A `JSON.stringify` replacer dropping a schema's prose. Only a string-valued + * `description` or `title` is prose: a property NAMED `description` maps to a + * schema object and is kept. + */ +function withoutProse(key: string, value: unknown): unknown { + return (key === 'description' || key === 'title') && typeof value === 'string' ? undefined : value; +} + +/** One derivation's root-property walks ({@link collectSitesFromSchema}); never shared across derivations. */ +type SharedWalkCache = Map>; + /** * Build the reference-site index from the metadata type schemas. * @@ -573,10 +641,11 @@ export function deriveReferenceSites(): ReferenceSiteIndex { const sites = new Map(); const unwalkable: string[] = []; + const sharedWalks: SharedWalkCache = new Map(); // Limb 1 — every declared type's own schema. for (const type of declaredTypes) { - const schema = getMetadataTypeSchema(type); + const schema = declaredTypeSchema(type); if (!schema) { unwalkable.push(type); continue; @@ -588,7 +657,7 @@ export function deriveReferenceSites(): ReferenceSiteIndex { unwalkable.push(type); continue; } - collectSitesFromSchema(type, json, singular, plural, sites); + collectSitesFromSchema(type, json, singular, plural, sites, sharedWalks); } // Limb 2 — flow node configs. See the module header: `FlowSchema` declares diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index bb473270b1f..70420f42652 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1980,6 +1980,30 @@ class ExtensionMembershipKeysRefusedError extends Error { } } +/** + * [#28574] The refusals an `extend` contribution meets at registration, before + * anything is materialized: `defaultRowScope` + * ({@link ExtensionDefaultRowScopeRefusedError}) and `membershipKeys` + * ({@link ExtensionMembershipKeysRefusedError}), in that order. `undefined` + * when the contribution carries neither. + * + * The ONE copy of both: {@link SchemaRegistry.registerObject} throws what this + * answers, and the metadata save and publish doors ask it of an + * `object_extension` item (through + * {@link SchemaRegistry.extendContributionRefusal}) so the item is refused at + * the door with the same sentence, instead of being stored and then failing + * every sync that registers it. + */ +export function extendContributionRefusal( + schema: { name?: unknown; defaultRowScope?: unknown; membershipKeys?: unknown }, + packageId: string | undefined, +): Error | undefined { + const name = String(schema.name ?? ''); + if (schema.defaultRowScope !== undefined) return new ExtensionDefaultRowScopeRefusedError(name, packageId); + if (schema.membershipKeys !== undefined) return new ExtensionMembershipKeysRefusedError(name, packageId); + return undefined; +} + /** * {@link ValidationRuleCopyRefusedError}'s code — `INVALID_METADATA`, for the * reason {@link EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE} gives: a body this @@ -3969,14 +3993,14 @@ export class SchemaRegistry { // {@link ExtensionDefaultRowScopeRefusedError}. Here, beside the field-`type` // door and for the same reason: before any materialization and before the // contributor list is opened, so a refused contribution leaves nothing behind. - if (ownership === 'extend' && (schema as { defaultRowScope?: unknown }).defaultRowScope !== undefined) { - throw new ExtensionDefaultRowScopeRefusedError(schema.name, packageId); - } // [#25234 ruling A, refusal 10] Nor does it declare a membership key — see // {@link ExtensionMembershipKeysRefusedError}. The same door, for the same - // reason, so a refused contribution leaves nothing behind. - if (ownership === 'extend' && (schema as { membershipKeys?: unknown }).membershipKeys !== undefined) { - throw new ExtensionMembershipKeysRefusedError(schema.name, packageId); + // reason, so a refused contribution leaves nothing behind. [#28574] Both + // are asked through {@link extendContributionRefusal}, the one copy the + // metadata save and publish doors also ask of an `object_extension` item. + if (ownership === 'extend') { + const extendRefusal = extendContributionRefusal(schema, packageId); + if (extendRefusal) throw extendRefusal; } // [ADR-0144 D6] A `summary` roll-up that reads a `record`-mode amount — // `sum` / `avg` / `min` / `max` of a child field whose rows each carry @@ -5786,6 +5810,75 @@ export class SchemaRegistry { return { ...owner.definition, validations: rules as ServiceObject['validations'] }; } + /** + * [#28574] {@link extendContributionRefusal}, asked of the registry by the + * metadata doors (which reach this registry through the engine and do not + * import this package). + */ + extendContributionRefusal( + schema: { name?: unknown; defaultRowScope?: unknown; membershipKeys?: unknown }, + packageId: string | undefined, + ): Error | undefined { + return extendContributionRefusal(schema, packageId); + } + + /** + * [#28574, ADR-0146 D10] Register an `object_extension` item — a stored row + * of a writable package — as that package's `'extend'` contributor of the + * object it names. + * + * The WHOLE body is the contribution: every key the extension's schema + * declares reaches {@link mergeObjectDefinitions}, never a hand-copied key + * list. Only the two addressing keys are taken off it: `extend` becomes the + * contribution's `name` (the object it registers under) and `priority` its + * merge priority ({@link DEFAULT_EXTENDER_PRIORITY} when absent). The body + * is the caller's PARSED body (`ObjectExtensionSchema`), so schema defaults + * and the parse's own rewrites are already applied. + * + * Re-registering replaces the package's previous contribution to the object + * (the `extend` arm of {@link registerObject}), so every sync that re-reads + * the stored row can call this again. Refusals are {@link registerObject}'s. + * + * @returns the FQN registered under. + */ + registerObjectExtension( + extension: { extend: string; priority?: number } & Record, + packageId: string, + ): string { + const { extend, priority, ...contribution } = extension; + return this.registerObject( + { ...contribution, name: extend } as unknown as ServiceObject, + packageId, + undefined, + 'extend', + typeof priority === 'number' ? priority : DEFAULT_EXTENDER_PRIORITY, + ); + } + + /** + * [#28574] Withdraw ONE package's `'extend'` contribution to an object — the + * delete of that package's `object_extension` item. The owner, the overlay + * layer and every other package's extension stay exactly where they are; a + * package's whole set leaves with {@link unregisterObjectsByPackage}. + * + * @returns whether a contribution was removed (idempotent: `false` when the + * package contributes no extension to the object). + */ + withdrawObjectExtension(name: string, packageId: string): boolean { + const fqn = this.resolveObjectKey(name) ?? name; + const contributors = this.objectContributors.get(fqn); + if (!contributors) return false; + const idx = contributors.findIndex(c => c.ownership === 'extend' && c.packageId === packageId); + if (idx === -1) return false; + contributors.splice(idx, 1); + if (contributors.length === 0) this.closeObjectEntry(fqn); + // The same two invalidations every other contributor mutation performs. + this.mergedObjectCache.delete(fqn); + this._objectRevision += 1; + this.log(`[Registry] Withdrew extend contribution to ${fqn} from ${packageId}`); + return true; + } + /** * [ADR-0029 D9.7] Remove the tenant OVERLAY layer of an object, leaving the * packaged owner exactly where it is — the layer-addressed sibling of diff --git a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts index 4bc2e243b56..e32c634d108 100644 --- a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts @@ -791,7 +791,7 @@ describe('#19403 round 7 — the provenance table agrees these leaves are now au }); describe('#19403 round 7 — the population, DERIVED from the registry and a shape', () => { - it('⭐ the registry and the catalog describe the same 31 metadata types', () => { + it('⭐ the registry and the catalog describe the same 32 metadata types', () => { // The ratchet's anchor. The catalog is GENERATED from this registry, so a // new metadata type arrives in both at once — which is what makes the bare // class below pick up a future unauthored display pair without anybody @@ -800,23 +800,25 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha // 28 → 29: `sharing_rule` joined it (ADR-0131 D6), bare with a description too. // 29 → 31: `queue` and `assignment_rule` joined it (ADR-0142 D1/D5), both bare // with a description, and both authored in the three locales on arrival. - expect(REGISTRY_TYPES.length).toBe(31); + // 31 → 32: `object_extension` joined it (ADR-0146 D10, #28574), bare with a + // description, authored in the three locales on arrival. + expect(REGISTRY_TYPES.length).toBe(32); expect([...REGISTRY_TYPES].sort()).toEqual(Object.keys(enMetadataForms as Record).sort()); }); it('the BARE predicate splits that population, and is read off the SHAPE not a name list', () => { // Lit. - expect(BARE_TYPES.length).toBe(14); + expect(BARE_TYPES.length).toBe(15); expect(PANEL_TYPES.length).toBe(17); expect(BARE_TYPES.length + PANEL_TYPES.length).toBe(REGISTRY_TYPES.length); - for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist', 'sharing_rule', 'queue', 'assignment_rule']) { + for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist', 'sharing_rule', 'queue', 'assignment_rule', 'object_extension']) { expect(BARE_TYPES, `${type} is bare`).toContain(type); } for (const type of AUTHORED_BARE_TYPES) expect(BARE_TYPES, `${type} is bare`).toContain(type); - // 24 leaves: fourteen labels, and a description on the ten that carry one. - expect(BARE_LEAVES.length).toBe(24); - expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(14); - expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(10); + // 26 leaves: fifteen labels, and a description on the eleven that carry one. + expect(BARE_LEAVES.length).toBe(26); + expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(15); + expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(11); expect(BARE_LEAVES.every((l) => ['label', 'description'].includes(l.prop))).toBe(true); }); @@ -835,8 +837,8 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(registryEntry('dataset')?.description, 'dataset carries a registry description like the six').toBeTruthy(); expect(isBare('dataset')).toBe(false); const described = REGISTRY_TYPES.filter((t) => typeof registryEntry(t)?.description === 'string'); - expect([...described].sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'queue', 'seed', 'sharing_rule']); - expect(described.filter(isBare).sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'doc', 'mapping', 'picklist', 'queue', 'seed', 'sharing_rule']); + expect([...described].sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'dataset', 'doc', 'mapping', 'object_extension', 'picklist', 'queue', 'seed', 'sharing_rule']); + expect(described.filter(isBare).sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'doc', 'mapping', 'object_extension', 'picklist', 'queue', 'seed', 'sharing_rule']); // And no panel type's own display pair echoes — the exclusion removes only // leaves that are already decided elsewhere or already authored. const panelEchoes: string[] = []; @@ -897,13 +899,13 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(flagged.length).toBe(BARE_LEAVES.length); }); - it('⭐ this class is now DONE — zero of its 24 leaves echoes in all three locales', () => { + it('⭐ this class is now DONE — zero of its 26 leaves echoes in all three locales', () => { const echoing = BARE_LEAVES.filter((l) => TRANSLATED_LOCALES.every(([, forms]) => forms[l.type]?.[l.prop] === l.en), ); expect(echoing.map((l) => `${l.type}.${l.prop}`)).toEqual([]); // Lit — and it really walked the class, which a zero alone would not show. - expect(BARE_LEAVES.length).toBe(24); + expect(BARE_LEAVES.length).toBe(26); }); }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 14a9b56561a..b2dea4377af 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -515,6 +515,10 @@ export const enMetadataForms: NonNullable = { } } }, + object_extension: { + label: "Object Extension", + description: "Fields and rules a package adds to an object another package defines" + }, field: { label: "Field", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 3f25d359f15..ac725b6e2fa 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -515,6 +515,10 @@ export const esESMetadataForms: NonNullable = } } }, + object_extension: { + label: "Extensión de Objeto", + description: "Campos y reglas que un paquete añade a un objeto que define otro paquete" + }, field: { label: "Campo", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 1817c75c3cd..9b67141450b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -515,6 +515,10 @@ export const jaJPMetadataForms: NonNullable = } } }, + object_extension: { + label: "オブジェクト拡張", + description: "パッケージが、別のパッケージの定義するオブジェクトに追加するフィールドとルール" + }, field: { label: "フィールド", sections: { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 51547361252..77308bfccd7 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1248,7 +1248,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 697 since #28158: the action form's `submitText` row (the verb on the // param dialog's submit button) — one row label, authored in all three // locales. - expect(translated.length, `${locale} positive control`).toBe(697); + // 698 since #28574 (ADR-0146 D10): `object_extension` joined the registry + // with its type-level display pair, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(698); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index cf1a74913aa..e279783d79e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -515,6 +515,10 @@ export const zhCNMetadataForms: NonNullable = } } }, + object_extension: { + label: "对象扩展", + description: "一个软件包为另一个软件包定义的对象添加的字段和规则" + }, field: { label: "字段", sections: { diff --git a/packages/runtime/src/domains/meta-object-extension-item.test.ts b/packages/runtime/src/domains/meta-object-extension-item.test.ts new file mode 100644 index 00000000000..46709567081 --- /dev/null +++ b/packages/runtime/src/domains/meta-object-extension-item.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28574, ADR-0146 D10 / D2 / D3] An object extension is an authorable item + * of a writable package: saved and published through the doors every item + * uses, registered as the package's `'extend'` contributor of its target, + * withdrawn when the row or the package goes, and exported as the package's + * `objectExtensions`. + * + * The business case: an administrator adds "Region" to the vendor's Account. + * The field is stored in `.custom`, served on every read of the object, + * stores values, travels with the pair's export and re-installs from it, and + * leaves when the row or the pair does. + * + * ## The rig + * + * A REAL store and protocol (the rig of `automation-flow-clone-custom-package.test.ts`): + * better-sqlite3 `:memory:` behind the real `ObjectQL` engine, the real + * `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` over its + * `SchemaRegistry`, and the real package export door (`HttpDispatcher`). The + * base is a code package registered through `registerApp` (the manifest + * door), so its object has a packaged owner and its table is real. The pair + * and the outside package are installed through the protocol's package door. + * + * Refusals assert the envelope (`code` + `status`) and that no row was left; + * the closure and suffix refusals also name the key (`issues[].path`). + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { ObjectExtensionSchema } from '@objectstack/spec/data'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +let home: string; +let priorHome: string | undefined; +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-object-extension-item-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +const TEST_PACKAGE_ID = 'objectstack-test'; +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; +/** A writable package that does not depend on the base: the account is outside its closure. */ +const OTHER = 'com.acme.other'; +const TARGET = 'crm_account'; +const TYPE = 'object_extension'; + +const baseManifest = () => ({ + id: BASE, + name: 'crm', + version: '3.4.1', + type: 'app', + namespace: 'crm', + objects: [{ + name: TARGET, + label: 'Account', + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, + }], +}); + +/** Studio's add-field body: an extension in the pair, its field suffixed. */ +const regionBody = (extra: Record = {}) => ({ + extend: TARGET, + fields: { region__c: { type: 'text', label: 'Region' } }, + ...extra, +}); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function freshEngine(): Promise { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; + engine.registerApp(baseManifest()); + await engine.syncSchemas(); + return engine; +} + +async function boot() { + const engine = await freshEngine(); + const protocol = new ObjectStackProtocolImplementation(engine as any, () => new Map()); + const p = protocol as any; + await p.installPackage({ + manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies: { [BASE]: '^3' } }, + }); + await p.installPackage({ manifest: { id: OTHER, name: 'other', version: '0.1.0', type: 'app', namespace: 'other' } }); + + const named: Record = { protocol, objectql: engine }; + const get = (name: string) => named[name] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (name: string) => get(name) }; + const dispatcher = new HttpDispatcher(kernel); + + /** A draft save naming the package, then the per-item publish naming it. */ + const saveAndPublish = async (packageId: string, item: unknown, name = TARGET) => { + await p.saveMetaItem({ type: TYPE, name, item, mode: 'draft', packageId }); + return p.publishMetaItem({ type: TYPE, name, packageId }); + }; + /** Every stored row of the type, read back from the store. */ + const rows = async () => ((await engine.find('sys_metadata', { where: { type: TYPE }, context: { isSystem: true } })) ?? []) + .map((r: any) => ({ name: r.name, package_id: r.package_id ?? null, state: r.state })); + /** The fields of the target on the three object reads and the registry. */ + const fieldsOn = async () => { + const byName = (await p.getMetaItem({ type: 'object', name: TARGET }))?.item; + const listed = ((await p.getMetaItems({ type: 'object' }))?.items ?? []).find((o: any) => o?.name === TARGET); + const layered = await p.getMetaItemLayered({ type: 'object', name: TARGET }); + return { + byName: Object.keys(byName?.fields ?? {}).sort(), + listed: Object.keys(listed?.fields ?? {}).sort(), + layered: Object.keys(layered?.effective?.fields ?? {}).sort(), + registry: Object.keys((engine.registry.getObject(TARGET) as any)?.fields ?? {}).sort(), + }; + }; + const extenders = () => engine.registry.getObjectContributors(TARGET) + .filter((c: any) => c.ownership === 'extend').map((c: any) => c.packageId); + const exportOf = async (id: string): Promise> => { + const reader: any = { + request: {}, + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['studio.access'] }, + }; + const r = await dispatcher.handlePackages(`/${encodeURIComponent(id)}/export`, 'GET', undefined, {}, reader); + expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(200); + return r.response?.body?.data; + }; + return { engine, protocol: p, saveAndPublish, rows, fieldsOn, extenders, exportOf }; +} + +const has = (list: string[]) => list.includes('region__c'); + +describe('[#28574] a writable pair saves and publishes an extension of the base\'s object', () => { + it('the field is on the by-name, list and layered object reads, with the pair as its source', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + + const seen = await rig.fieldsOn(); + expect(has(seen.byName), JSON.stringify(seen)).toBe(true); + expect(has(seen.listed), JSON.stringify(seen)).toBe(true); + expect(has(seen.layered), JSON.stringify(seen)).toBe(true); + expect(has(seen.registry), JSON.stringify(seen)).toBe(true); + expect(rig.extenders()).toEqual([PAIR]); + // The base field stays where it is. + expect(seen.byName).toContain('name'); + expect(await rig.rows()).toEqual([{ name: TARGET, package_id: PAIR, state: 'active' }]); + }, 60_000); + + it('a draft is not live: the field appears only on publish', async () => { + const rig = await boot(); + await rig.protocol.saveMetaItem({ type: TYPE, name: TARGET, item: regionBody(), mode: 'draft', packageId: PAIR }); + expect(has((await rig.fieldsOn()).registry)).toBe(false); + await rig.protocol.publishMetaItem({ type: TYPE, name: TARGET, packageId: PAIR }); + expect(has((await rig.fieldsOn()).registry)).toBe(true); + }, 60_000); + + it('a record stores a value in the field (the target\'s table gains the column on publish)', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + + const created: any = await rig.engine.insert(TARGET, { name: 'Acme', region__c: 'EMEA', organization_id: 'org_1' }, { context: { isSystem: true } }); + const back: any = await rig.engine.findOne(TARGET, { where: { id: created.id }, context: { isSystem: true } }); + expect(back?.region__c).toBe('EMEA'); + }, 60_000); + + it('the whole parsed body is registered: an extension\'s `validations` reach the object', async () => { + const rig = await boot(); + const rule = { name: 'region_required', type: 'script', condition: 'region__c == null', message: 'Region is required', events: ['insert'] }; + await rig.saveAndPublish(PAIR, regionBody({ validations: [rule] })); + + const names = ((rig.engine.registry.getObject(TARGET) as any)?.validations ?? []).map((v: any) => v?.name); + expect(names).toContain('region_required'); + }, 60_000); + + it('a boot re-reads the stored row and registers it the same way', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + // The registry forgets the contribution, as a fresh process would. + (rig.engine.registry as any).withdrawObjectExtension?.(TARGET, PAIR); + rig.engine.registry.unregisterObjectsByPackage(PAIR, true); + expect(has((await rig.fieldsOn()).registry)).toBe(false); + + const result = await rig.protocol.loadMetaFromDb(); + expect(result.errors).toBe(0); + expect(has((await rig.fieldsOn()).registry)).toBe(true); + expect(rig.extenders()).toEqual([PAIR]); + }, 60_000); +}); + +describe('[#28574] the pair\'s export carries the extension, and it re-installs', () => { + it('`GET /packages//export` carries the row as an `objectExtensions` entry that parses', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + + const manifest = await rig.exportOf(PAIR); + expect(manifest.objectExtensions, JSON.stringify(manifest)).toHaveLength(1); + const [entry] = manifest.objectExtensions; + expect(entry.extend).toBe(TARGET); + expect(Object.keys(entry.fields)).toEqual(['region__c']); + const reparsed = ObjectExtensionSchema.safeParse(entry); + expect(reparsed.success, JSON.stringify(reparsed.error?.issues)).toBe(true); + }, 60_000); + + it('the base\'s export is unchanged: it carries no extension', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + + const manifest = await rig.exportOf(BASE); + expect(manifest.objectExtensions).toBeUndefined(); + }, 60_000); + + it('re-installing the export into a fresh kernel registers the same field', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + const manifest = await rig.exportOf(PAIR); + + const fresh = await freshEngine(); + expect(Object.keys((fresh.registry.getObject(TARGET) as any)?.fields ?? {})).not.toContain('region__c'); + fresh.registerApp({ ...manifest, namespace: 'crm_custom', dependencies: { [BASE]: '^3' } }); + expect(Object.keys((fresh.registry.getObject(TARGET) as any)?.fields ?? {})).toContain('region__c'); + expect(fresh.registry.getObjectContributors(TARGET).filter((c: any) => c.ownership === 'extend').map((c: any) => c.packageId)) + .toEqual([PAIR]); + }, 60_000); +}); + +describe('[#28574] withdrawal: deleting the row, or uninstalling the pair, removes the field from every read', () => { + it('deleting the row', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + const before = await rig.fieldsOn(); + expect([before.byName, before.listed, before.layered, before.registry].every(has), JSON.stringify(before)).toBe(true); + + await rig.protocol.deleteMetaItem({ type: TYPE, name: TARGET, packageId: PAIR }); + + const seen = await rig.fieldsOn(); + expect(has(seen.byName) || has(seen.listed) || has(seen.layered) || has(seen.registry), JSON.stringify(seen)).toBe(false); + expect(seen.registry).toContain('name'); + expect(rig.extenders()).toEqual([]); + expect(await rig.rows()).toEqual([]); + }, 60_000); + + it('uninstalling the pair', async () => { + const rig = await boot(); + await rig.saveAndPublish(PAIR, regionBody()); + const before = await rig.fieldsOn(); + expect([before.byName, before.listed, before.layered, before.registry].every(has), JSON.stringify(before)).toBe(true); + + await rig.protocol.deletePackage({ packageId: PAIR }); + + const seen = await rig.fieldsOn(); + expect(has(seen.byName) || has(seen.listed) || has(seen.layered) || has(seen.registry), JSON.stringify(seen)).toBe(false); + expect(rig.extenders()).toEqual([]); + }, 60_000); +}); + +describe('[#28574] refused at the door, leaving no row', () => { + const refusals: Array<[string, string, Record, string, number, string | undefined]> = [ + ['a target outside the package\'s closure', OTHER, { extend: TARGET, fields: { region: { type: 'text', label: 'Region' } } }, 'VALIDATION_ERROR', 400, 'extend'], + ['an unknown target', PAIR, { extend: 'crm_nowhere', fields: { region__c: { type: 'text', label: 'Region' } } }, 'VALIDATION_ERROR', 400, 'extend'], + ['a `.custom` field without `__c` (N-23b)', PAIR, { extend: TARGET, fields: { region: { type: 'text', label: 'Region' } } }, 'VALIDATION_ERROR', 400, 'fields.region'], + ['`defaultRowScope` on the body', PAIR, regionBody({ defaultRowScope: 'owner' }), 'VALIDATION_ERROR', 400, 'defaultRowScope'], + ['`membershipKeys` on the body', PAIR, regionBody({ membershipKeys: [] }), 'VALIDATION_ERROR', 400, 'membershipKeys'], + ]; + + it.each(refusals)('%s', async (_label, packageId, body, code, status, path) => { + const rig = await boot(); + const name = typeof body.extend === 'string' ? body.extend : TARGET; + let caught: any; + try { + await rig.protocol.saveMetaItem({ type: TYPE, name, item: body, mode: 'draft', packageId }); + } catch (e) { + caught = e; + } + expect(caught, 'the save was admitted').toBeDefined(); + expect({ code: caught.code, status: caught.status }).toEqual({ code, status }); + if (path !== undefined) expect((caught.issues ?? []).map((i: any) => i.path)).toContain(path); + expect(await rig.rows()).toEqual([]); + expect(rig.extenders()).toEqual([]); + }, 60_000); + + it('a read-only package: the base itself', async () => { + const rig = await boot(); + let caught: any; + try { + await rig.protocol.saveMetaItem({ type: TYPE, name: TARGET, item: regionBody(), mode: 'draft', packageId: BASE }); + } catch (e) { + caught = e; + } + expect(caught, 'the save was admitted').toBeDefined(); + expect(caught.status).toBeGreaterThanOrEqual(400); + expect(caught.status).toBeLessThan(500); + expect(await rig.rows()).toEqual([]); + }, 60_000); + + it('a package-less write: an extension is an item of a package, so a save naming none is refused', async () => { + const rig = await boot(); + let caught: any; + try { + await rig.protocol.saveMetaItem({ type: TYPE, name: TARGET, item: regionBody(), mode: 'draft' }); + } catch (e) { + caught = e; + } + expect({ code: caught?.code, status: caught?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(String(caught?.message)).toContain('names none'); + expect(await rig.rows()).toEqual([]); + expect(rig.extenders()).toEqual([]); + }, 60_000); + + it('a row filed under a name other than its target', async () => { + const rig = await boot(); + let caught: any; + try { + await rig.protocol.saveMetaItem({ type: TYPE, name: 'crm_contact', item: regionBody(), mode: 'draft', packageId: PAIR }); + } catch (e) { + caught = e; + } + expect({ code: caught?.code, status: caught?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(await rig.rows()).toEqual([]); + }, 60_000); + + it('the `excluded` override disposition does not close the type: the pair\'s save is admitted', async () => { + const rig = await boot(); + await expect(rig.protocol.saveMetaItem({ type: TYPE, name: TARGET, item: regionBody(), mode: 'draft', packageId: PAIR })) + .resolves.toBeDefined(); + expect(await rig.rows()).toEqual([{ name: TARGET, package_id: PAIR, state: 'draft' }]); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 05d05a1e276..381c623f394 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3291,11 +3291,20 @@ context: HttpProtocolContext, const exportPluralKeys = Object.keys(PLURAL_TO_SINGULAR).filter( (k) => k !== 'datasources' && k !== 'emailTemplates', ); + // [#28574, ADR-0146 D2 / D10] A package's `object_extension` items travel + // as its `objectExtensions`, the collection the install door registers as + // `'extend'` contributors (`ObjectQL.registerApp`). `PLURAL_TO_SINGULAR` + // carries no such pair — an extension merges into its target rather than + // registering under a type of its own — so the pair is stated here, where + // the item is read back out as the collection entry it is. + const EXTENSIONS_KEY = 'objectExtensions'; + const singularOf = (plural: string): string => + plural === EXTENSIONS_KEY ? 'object_extension' : PLURAL_TO_SINGULAR[plural]; const manifest: Record = {}; let total = 0; - for (const plural of exportPluralKeys) { - const singular = PLURAL_TO_SINGULAR[plural]; + for (const plural of [...exportPluralKeys, EXTENSIONS_KEY]) { + const singular = singularOf(plural); let items: any[] = []; try { // getMetaItems applies the packageId filter at the @@ -3330,6 +3339,20 @@ context: HttpProtocolContext, total += views.length + viewItems.length; continue; } + // [#28574] A package's `objects` are the objects it OWNS. The registry + // lists an object under every package that contributes to it — the + // one that extends it too — served as its owner's merged object + // (`_packageId` the owner's). Exported, that would re-install as an + // `'own'` claim on another package's object, refused at install + // (`ObjectOwnershipConflictError`), and the extender's own fields + // travel as its `objectExtensions` entry instead. + if (plural === 'objects') { + items = items.filter((it) => { + const owner = (it as { _packageId?: unknown } | null)?._packageId; + return owner === undefined || owner === null || owner === packageId; + }); + if (items.length === 0) continue; + } manifest[plural] = items.map(clean); total += items.length; } diff --git a/packages/spec/src/kernel/customization-package.ts b/packages/spec/src/kernel/customization-package.ts index 1961bd29774..f00a8064f38 100644 --- a/packages/spec/src/kernel/customization-package.ts +++ b/packages/spec/src/kernel/customization-package.ts @@ -339,6 +339,10 @@ export const OVERRIDE_TYPE_CLASSIFICATION = { }, // ── D10: extended, never replaced ────────────────────────────────── object: { disposition: 'extend_only', basis: 'adr_0146_d10_extended' }, + // An extension is the D10 mechanism itself — its own item of the writing + // package, stored under its target's name — so no declared override + // replaces one; a `.custom` pair writes its own (#28574). + object_extension: { disposition: 'excluded', basis: 'adr_0146_d10_extended' }, // ── D6: the security catalog's one-name-per-deployment types ────── permission: securityCatalog, position: securityCatalog, diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 208a6a21533..fe888e6be29 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -77,6 +77,7 @@ import { lazySchema } from '../shared/lazy-schema'; export const MetadataTypeSchema = lazySchema(() => z.enum([ // Data Protocol 'object', // Business entity definition (ObjectSchema) + 'object_extension', // Fields and rules a package adds to an object another package defines (ObjectExtensionSchema; ADR-0146 D10) 'field', // Standalone field definition (FieldSchema) // ADR-0088: there is no `trigger` metadata type — sync data-layer logic is a // `hook`; async automation is a `record_change` flow. @@ -803,6 +804,28 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // namespace and are free to evolve. (Mirrors Salesforce: standard objects // are not modifiable per-org beyond layout/label; custom objects are full). { type: 'object', label: 'Object', filePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 10, domain: 'data' }, + // [ADR-0146 D10, #28574] `object_extension` — an extension of an object + // another package defines (`ObjectExtensionSchema`: fields, rules, indexes, + // labels, merged by the ObjectQL registry as an `'extend'` contributor) is + // an item of the package that writes it: a base's paired `.custom` (Studio's + // add-field, N-23c) or any writable package whose dependency closure holds + // the target. One item per package per target, stored under the target's + // name; its body carries `extend` and no `name`. Draft, publish, history, + // rollback and delete are the doors every item uses, and the package's + // export carries the item as an `objectExtensions` entry. + // + // `filePatterns`: the `*.extension.ts` convention a code package's + // `defineObjectExtension` files follow (`examples/app-showcase/src/data/ + // extensions/`). A code package declares its extensions in + // `objectExtensions`, which the artifact loader already stores under this + // type name. `supportsOverlay` / + // `allowEnvironmentOverlay: false`: an extension is never overlaid; a + // package changes its own item, and a second package writes a second item. + // The body is judged by the metadata-protocol doors with + // `ObjectExtensionSchema` itself; this type has no entry in + // `BUILTIN_METADATA_TYPE_SCHEMAS` (the ADR-0010 envelope there would change + // the schema a manifest's `objectExtensions` parses with). + { type: 'object_extension', label: 'Object Extension', description: 'Fields and rules a package adds to an object another package defines', filePatterns: ['**/*.extension.ts'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 11, domain: 'data' }, // [#7893] `field` — the STANDALONE runtime-create door is RETIRED // (`allowRuntimeCreate: false`), maintainer ruling 2026-08-12. // diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts index 638e08f2cc5..7f2719fb2ca 100644 --- a/packages/spec/src/meta-spelling/meta-url-data.generated.ts +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -48,6 +48,8 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "emailTemplates": "email_template", "docs": "doc", "books": "book", + "object_extensions": "object_extension", + "objectExtensions": "object_extension", "fields": "field", "seeds": "seed", "external_catalogs": "external_catalog", @@ -64,6 +66,7 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre */ export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze([ "object", + "object_extension", "field", "hook", "seed",