From 299f4c2ba11f17a1361d33693acd622f6363a989 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:33:46 +0000 Subject: [PATCH 1/8] refactor(metadata-protocol): move the protocol.ts preamble into 22 themed modules, verbatim Every top-level statement above `export class ObjectStackProtocolImplementation` (183 declarations) moves, byte for byte with its leading comments, into a themed `protocol-*.ts` module. Only import lists, export lists and a module header comment are new; protocol.ts re-exports every name it exported before. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .../src/protocol-audit-entry.ts | 89 + .../src/protocol-authoring-channel.ts | 120 + .../src/protocol-authoring-refusals.ts | 373 + .../src/protocol-bundle-members.ts | 154 + .../src/protocol-client-failures.ts | 866 +++ .../src/protocol-data-request-shapes.ts | 105 + .../src/protocol-discovery-services.ts | 104 + .../src/protocol-door-requests.ts | 131 + .../src/protocol-list-query-params.ts | 948 +++ .../src/protocol-meta-type-schema.ts | 620 ++ .../src/protocol-metadata-issues.ts | 318 + .../src/protocol-mutation-events.ts | 124 + .../src/protocol-overlay-merge.ts | 397 + .../src/protocol-package-bindings.ts | 432 ++ .../src/protocol-package-lifecycle-types.ts | 184 + .../src/protocol-publish-backfill-request.ts | 121 + .../src/protocol-reference-hits.ts | 155 + .../src/protocol-stored-overlay-types.ts | 99 + .../src/protocol-version-tokens.ts | 313 + .../src/protocol-view-normalization.ts | 432 ++ .../src/protocol-write-constants.ts | 34 + .../src/protocol-write-face.ts | 468 ++ packages/metadata-protocol/src/protocol.ts | 6581 +---------------- 23 files changed, 6866 insertions(+), 6302 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol-audit-entry.ts create mode 100644 packages/metadata-protocol/src/protocol-authoring-channel.ts create mode 100644 packages/metadata-protocol/src/protocol-authoring-refusals.ts create mode 100644 packages/metadata-protocol/src/protocol-bundle-members.ts create mode 100644 packages/metadata-protocol/src/protocol-client-failures.ts create mode 100644 packages/metadata-protocol/src/protocol-data-request-shapes.ts create mode 100644 packages/metadata-protocol/src/protocol-discovery-services.ts create mode 100644 packages/metadata-protocol/src/protocol-door-requests.ts create mode 100644 packages/metadata-protocol/src/protocol-list-query-params.ts create mode 100644 packages/metadata-protocol/src/protocol-meta-type-schema.ts create mode 100644 packages/metadata-protocol/src/protocol-metadata-issues.ts create mode 100644 packages/metadata-protocol/src/protocol-mutation-events.ts create mode 100644 packages/metadata-protocol/src/protocol-overlay-merge.ts create mode 100644 packages/metadata-protocol/src/protocol-package-bindings.ts create mode 100644 packages/metadata-protocol/src/protocol-package-lifecycle-types.ts create mode 100644 packages/metadata-protocol/src/protocol-publish-backfill-request.ts create mode 100644 packages/metadata-protocol/src/protocol-reference-hits.ts create mode 100644 packages/metadata-protocol/src/protocol-stored-overlay-types.ts create mode 100644 packages/metadata-protocol/src/protocol-version-tokens.ts create mode 100644 packages/metadata-protocol/src/protocol-view-normalization.ts create mode 100644 packages/metadata-protocol/src/protocol-write-constants.ts create mode 100644 packages/metadata-protocol/src/protocol-write-face.ts diff --git a/packages/metadata-protocol/src/protocol-audit-entry.ts b/packages/metadata-protocol/src/protocol-audit-entry.ts new file mode 100644 index 00000000000..d5546e2d6ad --- /dev/null +++ b/packages/metadata-protocol/src/protocol-audit-entry.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata +// audit entry, the pending-audit carrier and the preflight violation codes. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + type MetadataLock, +} from '@objectstack/spec/kernel'; + +/** + * One `sys_metadata_audit` row as a VALUE — exactly the entry + * `recordMetadataAudit` writes, named so a refusal can hand its row to whoever + * is in a position to make it durable instead of writing it where it stands. + * + * [#8594] Why the entry became a value. `promoteDraftForPublish` used to audit + * its own denials inline, and it has two callers standing on opposite sides of + * a transaction boundary: `publishMetaItem` runs it in no transaction of its + * own, `publishPackageDrafts` runs it inside ONE `engine.transaction()` for the + * whole batch (ADR-0067 D2). A denial row written inline therefore landed on + * the single-item route and ROLLED BACK on the batch route — destroyed by the + * very rollback its own refusal had caused, which is the defect #7748 exists to + * close. Making the row data lets the helper stay ignorant of whose transaction + * it is inside (it cannot know, and guessing is what broke) and puts the + * placement decision where the transaction actually is: in the caller. + */ +export interface MetadataAuditEntry { + type: string; + name: string; + /** + * [ADR-0131 D6/D7] Always environment-wide: the audit ledger is + * deployment-level, and no metadata write is organization-scoped any more. + * Typed `null` so an organization cannot be recorded by accident. + */ + organizationId?: null; + operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset'; + outcome: 'allowed' | 'denied' | 'forced'; + code: string; + lockState?: MetadataLock; + lockOverridden?: boolean; + actor?: string; + source?: string; + requestId?: string; + note?: string; +} + +/** Attach the denial row `err` owes, and return `err` so call sites read as one `throw`. */ +function withPendingAudit(err: E, audit: MetadataAuditEntry): E { + (err as E & { __pendingAudit?: MetadataAuditEntry }).__pendingAudit = audit; + return err; +} + +/** + * [#8595] The PRE-FLIGHT gate's refusal vocabulary — the closed set of codes + * `publishPackageDrafts` can refuse a batch with BEFORE it opens its + * transaction, and the spelling each one takes in `sys_metadata_audit.code`. + * + * Why a declared map and not `code.toLowerCase()`. `batch_aborted` (#8400) + * deliberately took ONE fixed value because its causal code is whatever the + * error catalog threw, and lower-casing that would turn this column's + * documented closed set into an open one that grows with the catalog. The + * pre-flight codes are a different animal — they are minted in + * `publishPackageDrafts`'s own pre-flight block, not caught from below — so the + * violation's own code CAN be carried honestly, which is what a compliance + * query filters on ("show me every publish refused by the namespace rule"). + * + * The precedent's principle still binds, though: the column must not drift + * silently. Hence `Record` over an index signature — + * a new pre-flight gate (ADR-0121's endpoint gates, should #2657 Part B bring + * them back) cannot reach this column without failing to compile until its + * audit spelling is declared here AND added to the column's documented set in + * `sys-metadata-audit.object.ts`. A mechanical lower-case would have let it + * land silently; a runtime fallback would have put an unreachable value in a + * documented closed set. The compiler is the enforcement. + */ +type PreflightViolationCode = 'NAMESPACE_PREFIX' | 'STORED_TYPE_NOT_CANONICAL'; + +const PREFLIGHT_AUDIT_CODE: Record = { + // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary + NAMESPACE_PREFIX: 'namespace_prefix', + // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary + STORED_TYPE_NOT_CANONICAL: 'stored_type_not_canonical', +}; + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { PREFLIGHT_AUDIT_CODE, withPendingAudit }; +export type { PreflightViolationCode }; diff --git a/packages/metadata-protocol/src/protocol-authoring-channel.ts b/packages/metadata-protocol/src/protocol-authoring-channel.ts new file mode 100644 index 00000000000..b1c4a0b1363 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-authoring-channel.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the authoring +// gate, the credential channel and the authoring channel contracts. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * Pre-persistence authoring gate (ADR-0094 addendum seam; #3050). + * + * Unlike the post-persist {@link MetadataMutationProjector} (best-effort, + * never thrown), an authoring gate runs BEFORE persistence and REJECTS the + * write by throwing — it is the seam for domain invariants that must hold on + * every runtime-authored body regardless of which HTTP surface produced it + * (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN + * a packaged object's `sharingModel`). + * + * Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after + * the ADR-0005 overlay/runtime-create authorization and the per-type spec + * validation — so `publishMetaItem` promotes an already-gated body and needs + * no second gate. Environment writes only: control-plane bootstrap writes + * (`environmentId === undefined`) are the package author's own channel and + * bypass the gate, mirroring the ADR-0005 gate above. + */ +export interface MetadataAuthoringGateContext { + /** Singular type name (e.g. `object`). */ + type: string; + name: string; + /** Lifecycle the body is being saved into. */ + state: 'draft' | 'active'; + /** The body being persisted. */ + body: unknown; + /** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */ + isArtifactBacked: boolean; + /** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */ + declaredBody?: unknown; +} + +export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise; + +/** + * [#20790] A metadata type's WRITE-ONLY credential channel — where the + * credentials its bodies carry are stored instead of in the body, on the + * platform's one secret seam (#7799: a `secret`-typed field the engine + * encrypts, masks on every read and dereferences only through + * `resolveSecretField`). Registered per type by the domain plugin that owns + * the type's credential-location table (the automation plugin holds `flow`'s), + * beside its authoring gate. + * + * Every write that lands a body at rest consults it: + * - `saveMetaItem` calls {@link store} immediately before the put, after the + * carry-forward, and persists what it returns; + * - the runtime authoring gate reads {@link heldPaths} as restored positions, + * on an active save and on the draft → active promotion; + * - a restore (rollback, revert) stores {@link strip}'s body. + * + * ⛔ Not a second secret mechanism and not a per-door redaction: the read + * projection stays the type's redactor; this only decides where a credential + * is STORED. + */ +export interface MetadataCredentialChannel { + /** + * Move every explicit credential in `body` into the channel for + * `(name, state)` and return the body without any — what is stored. An + * absent credential means "unchanged". THROWS (with an ADR-0112 + * `code`/`status`) to refuse the save; nothing may have been put then. + */ + store(args: { name: string; state: 'draft' | 'active'; body: unknown }): Promise; + /** + * The positions in `item` (dotted, item-relative — `redactedKeys`' + * spelling) whose credential is withheld from the body and held by the + * channel, so the gate reads them as present. `state: 'draft'` for a draft + * being promoted. + */ + heldPaths(args: { name: string; state: 'draft' | 'active'; item: unknown }): Promise; + /** + * The body a restore stores: every credential removed, and nothing written + * to the channel — it keeps its current credential. + */ + strip(body: unknown): unknown; +} + +/** + * Which authoring channel a kernel's metadata writes arrive on (#6710). + * + * ADR-0005 carves out "the package author's own bootstrap channel" from the + * runtime authoring rules: a control-plane kernel installing a package is not + * an author publishing into a live tenant, and gating it would refuse the very + * bodies the platform ships. The carve-out is legitimate and stays. + * + * What #6710 changed is **how a kernel says it is that channel**. Until this + * type existed the answer was inferred from `environmentId === undefined` — a + * ROW-SCOPING key pressed into service as a topology signal. That proxy lies: + * the CLI's lightweight host-config assembler (`serve.ts`'s auto-register + * branch, `new ObjectQLPlugin()` with no options) also leaves `environmentId` + * undefined, and it serves an END-USER `PUT /api/v1/meta/*`. Both topologies + * read identically, so the whole #4463 gate — all the shared `AUTHORING_RULES` + * — was disengaged on a self-hosted app server. #5086 had already moved the + * code-only refusal off the same proxy for the same reason ("keying + * authorization off a row-scoping key is what made a type-level declaration + * depend on deployment topology; the declaration decides it here instead"). + * + * So the channel is now DECLARED, not inferred: + * + * - `'environment'` — metadata arrives from an author (Studio tenant, MCP/AI + * agent, `PUT /api/v1/meta/*`). The gate runs. **This is the default**, and + * the default is the whole point: an assembly that forgets to declare gets + * MORE enforcement, never less, so the next assembly variant nobody thought + * about cannot silently reopen this hole. + * - `'package-author'` — this kernel IS the package author's own bootstrap + * channel. Only the genuine control-plane assembly may state this. + * + * Deliberately a channel NAME and not a boolean: `skipAuthoringRules: true` + * would be the same bytes with the opposite meaning — a kill switch any + * assembly could reach for to make a red publish go away. A caller has to + * claim to BE the package author to be treated as one. + */ +export type MetadataAuthoringChannel = 'environment' | 'package-author'; diff --git a/packages/metadata-protocol/src/protocol-authoring-refusals.ts b/packages/metadata-protocol/src/protocol-authoring-refusals.ts new file mode 100644 index 00000000000..d137bdd952d --- /dev/null +++ b/packages/metadata-protocol/src/protocol-authoring-refusals.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the disabled- +// package and no-draft refusals, and the save-door body refusals (runtime hooks, permission- +// set owner binding, protection block, packageless publish). +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { judgePermissionSetOwnerBinding } from '@objectstack/spec/security'; +import { overlaySchemaDeclaredKey } from './protocol-meta-type-schema.js'; +import type { MetadataIssueEntry } from './protocol-metadata-issues.js'; + +/** + * [#7557, #24097] The ONE refusal for an object whose owning package is + * disabled, raised by every API reader of that object: the data plane + * ({@link ObjectStackProtocolImplementation.assertObjectRegistered}) and the + * by-name `/meta` reads (`getMetaItem`, `getMetaItemLayered`). One constructor, + * so the two halves of the answer cannot drift apart in code, status or text. + */ +export function objectPackageDisabledError(object: string): Error { + const disabled: any = new Error( + `Object '${object}' belongs to a disabled package and is not being served. ` + + 'Re-enable the package to restore access.', + ); + disabled.code = 'OBJECT_PACKAGE_DISABLED'; + disabled.status = 404; + disabled.object = object; + return disabled; +} + +/** + * [#7557, #24097] Whether `registry` says `name` is an object of a disabled + * package. Optional-called: registry doubles across the test suites implement + * `isPackageDisabled` but not this, and a host whose registry cannot answer + * must not have every read refused. + */ +export function isObjectOfDisabledPackage(registry: any, name: string): boolean { + return typeof registry?.isObjectPackageDisabled === 'function' + && registry.isObjectPackageDisabled(name) === true; +} + +/** + * [#7557, #24210] Whether `item` belongs to a disabled package: its + * `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a + * non-object item, asked by the list read ({@link + * ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and + * the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem}, + * which answers as for a name with nothing behind it), so the two cannot + * disagree about which items a disable hides. Optional-called, as {@link + * isObjectOfDisabledPackage} is: a host whose registry cannot answer has no + * item hidden. + */ +export function isItemOfDisabledPackage(registry: any, item: unknown): boolean { + return typeof registry?.isPackageDisabled === 'function' + && registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true; +} + +/** + * [#28877] Whether a disable withholds the `action` item `item` from the SERVED + * reads: the registry's `isActionWithheldByDisabledPackage`, the ONE predicate + * its served object fold (#24400's bound-action attach) asks — its own package + * disabled, its `objectName` object in a disabled package, or it launches into + * one (a `type: 'flow'` target only disabled packages ship, or a param whose + * `objectOverride` object belongs to a disabled package). Asked by the effective served list ({@link + * ObjectStackProtocolImplementation.getMetaItems}) and the served by-name read + * of an `action`, so `GET /meta/action` and the console's object read withhold + * the same actions. The execution face and the per-package layer (the package + * export) do not ask it: they keep what each package ships. Optional-called, + * as {@link isItemOfDisabledPackage} is: a registry that cannot answer hides + * nothing more than that predicate does. + */ +export function isActionWithheldByDisabledPackage(registry: any, item: unknown): boolean { + return typeof registry?.isActionWithheldByDisabledPackage === 'function' + && registry.isActionWithheldByDisabledPackage(item) === true; +} + +/** + * The strict draft read's `404 NO_DRAFT`: no pending draft at this address + * (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that + * finds no draft row and the read of a disabled package's draft, so the two + * answers are byte-identical. + */ +export function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } { + const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 }; + err.code = 'NO_DRAFT'; + err.status = 404; + return err; +} + +/** + * [#21658, #21689] The save door's refusal of a `hook` that carries no `body`: + * such a hook can never run once this door has stored it. One predicate, two + * shapes it meets, one envelope: a hook whose `handler` names a function, and a + * hook with neither field. + * + * Why it can never run. A hook this door stores ships with no code package: + * the runtime binds every stored hook under the synthetic owner + * `metadata-service` (`ObjectQLPlugin`'s authored hook re-sync), with no + * `functions` map, and no package of that name registers functions. So a + * `body`, which is stored with the hook, is the only code it can run. + * + * - A `handler` name resolves inside the hook's own package only (the + * maintainer's ruling on #21604, letter B; the binder's `resolveHandler` in + * `@objectstack/objectql`'s `hook-binder.ts`), so it has nothing to resolve + * against, and the binder refuses the hook at registration + * (`INVALID_REFERENCE` / 400, logged at `error`). + * - A hook with neither field has nothing to bind at all, and the binder + * skips it at every re-sync (`skipping hook with unresolved handler`, + * logged at `warn`). + * + * Either way the door would already have answered success, and the hook would + * be served by name and never run. Refusing it here says so to the author, + * before anything is stored. + * + * The predicate is the binder's own body-first test, the judgement + * install-local's `collectHooksWithoutBody` makes on its own door (#21585): a + * `body` object is bound through the body runner and the `handler` is never + * consulted, so a hook carrying BOTH a `body` and a `handler` saves (its body + * runs), and every hook without a `body` object is refused, whatever its + * `handler` holds. Asked after the type schema has accepted the body, so + * `body` here is either absent or a declared hook body, and a malformed `body` + * gets the schema's own located `422` instead of this refusal's "give it a + * body". + * + * ⛔ Not a `HookSchema` rule: a build artifact legitimately carries the string + * form (`objectstack build` lowers an inline function to the hook's name and + * ships the function in the artifact's runtime module), and the artifact and + * boot doors never reach `saveMetaItem`. This is the runtime-authoring door's + * rule only. + * + * Every writer through this door is judged: the REST and dispatcher saves, in + * draft and in publish mode, and the two server-stated re-savers + * (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as + * the row's failure. A row stored before this rule keeps its bytes. + * + * `VALIDATION_ERROR` / 400, the envelope of the name check the door runs on + * every body (`savedItemNameRefusal`). The message names the hook (and the + * function, when its `handler` names one), prescribes the `body` first, and + * only then explains: a 4xx message crosses the REST boundary bounded at 500 + * characters with its TAIL truncated, and the whole sentence stays under that + * bound for any hook and function name shorter than about 65 characters each. + * Runtime words carry no tracker number. + */ +export function runtimeHookWithoutBodyRefusal( + singularType: string, + item: unknown, + saveName: string, +): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined { + if (singularType !== 'hook') return undefined; + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const hook = item as { handler?: unknown; body?: unknown }; + if (hook.body && typeof hook.body === 'object') return undefined; + const prescription = 'Give it a `body` (sandboxed JS, `{ language: \'js\', source }`, or an expression), ' + + 'which is stored with the hook. A hook saved through the metadata API ships with no code package, so '; + const err = new Error( + typeof hook.handler === 'string' && hook.handler !== '' + ? `Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, ` + + `so it can never run. ${prescription}it holds no functions, and a \`handler\` name resolves only inside ` + + "the hook's own package." + : `Invalid hook: '${saveName}' carries no \`body\`, so it has nothing to run. ${prescription}` + + 'its `body` is the only code it can run.', + ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + return err; +} + +/** + * [ADR-0086 D3] The refusal of a permission set whose body `packageId` + * disagrees with the package binding of the row a write door writes it into. + * + * D3 declares the key as "the owning package for a package-shipped set + * (absent ⇒ env-authored)", and every reader of a set's owner reads the + * binding: the row's `package_id`, served as `_packageId`. A body that + * declared another owner was accepted and its declaration silently + * overridden, so an author (an AI build above all) was never told the owner it + * wrote is not the owner it got. Refused instead, at every door a body reaches + * `active` through: the save ({@link ObjectStackProtocolImplementation.saveMetaItem}, + * draft and publish mode), the restore of a stored version + * ({@link ObjectStackProtocolImplementation.rollbackMetaItem} and + * `revertCommit`'s restore limb, through `restoredBodyWriter`) and the draft + * promotion that the per-item publish and + * {@link ObjectStackProtocolImplementation.publishPackageDrafts} share — the + * three doors `savedItemNameRefusal` judges a body's `name` at. + * + * What disagrees, and the words, are the one judge every door of this rule + * calls: `judgePermissionSetOwnerBinding` (`@objectstack/spec/security`, whose + * header holds the verdict table), which the stack schema (the build door) and + * `ObjectQL.registerApp` (the registration door) call too. This function is the + * write doors' envelope around it, and its words for these three doors are byte + * for byte the words this function built before the judge moved. `binding` is + * the RESOLVED binding of the row the write lands in, never the request's + * spelling: a draft save that names no package lands in the package of the + * item's active row (#11087), a direct save that names none lands in the row + * its address resolves, and a restore writes into the item's active row, whose + * binding `put` keeps. + * + * `VALIDATION_ERROR` / 400, the envelope of the door's other body-against- + * address refusal: `savedItemNameRefusal`, which judges the body's `name` + * against the name the row is written under, at the same doors. No code is + * minted. `issues[]` locates the key, the shape the Studio designer keys on. + * + * Every writer through the save door is judged, the two server-stated + * re-savers included (`migrateStoredMetadata`, which records the refusal as + * the row's failure, and `duplicatePackage`, which rewrites a body naming its + * source package to the target first). A row stored before this rule keeps its + * bytes and keeps reading by its binding; it is refused on its next write. + */ +export function permissionSetOwnerBindingRefusal( + singularType: string, + item: unknown, + name: string, + binding: string | null | undefined, + door: 'save' | 'restore' | 'publish', +): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined { + if (singularType !== 'permission') return undefined; + const refusal = judgePermissionSetOwnerBinding(item, name, binding, door); + if (!refusal) return undefined; + const err = new Error(refusal.message) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = [{ path: 'packageId', message: refusal.issueMessage, code: 'custom' }]; + return err; +} + +/** The author-facing lock block (ADR-0010 Phase 4.3), as the spec mounts it on a metadata item. */ +const PROTECTION_BLOCK_KEY = 'protection'; + +/** + * [ADR-0010 §3.5 and Phase 4.3] The save door's refusal of a `protection` + * block — the package author's lock declaration, which this door cannot + * honour. + * + * ADR-0010 states one door for the block. Phase 4.3 calls it the public + * author surface "that translates into the private `_lock` envelope at load + * time": the loader "calls `applyProtection(item, { packageId })` to translate + * the block … and strips the public `protection` block so it never leaks into + * the overlay row". An item this door writes is authored in the environment, + * and §3.5 states what a lock means for one: "Tenant `org` artifacts ignore + * L3 / L4 entirely (the tenant owns them in full, can also delete them)". So + * the block has no translation here, and translating it anyway would open a + * lock door ADR-0010 does not state. Stored verbatim, which is what this door + * did before, the block was served back as `protection.lock: 'full'` while the + * item read `lock: 'none'` and every edit and delete succeeded: a body telling + * its reader the item is protected when it is not. + * + * Asked only on a type whose schema declares the block, read structurally by + * {@link overlaySchemaDeclaredKey} (the unmanaged import asks the same + * question). On any other type the schema's own verdict stands: an + * unrecognized key, refused by the parse. + * + * Every AUTHORING write is refused: the REST and dispatcher saves, in draft + * and in publish mode, the domain doors that save through this one, and the + * unmanaged import, which strips a block it can parse before the body reaches + * this door (`stripUnmanagedImportLock`) and hands a malformed one on to be + * refused here. The server-stated rewrites of rows the store already holds — + * a `source`-stating re-save (`migrateStoredMetadata`, the flow credential + * move) and `duplicatePackage` — strip the block instead, through + * {@link withoutProtectionBlock}, exactly as they strip the derived provenance + * stamps (`tenantAuthoredWriteRefusal` draws the same line): a row stored + * before this rule may carry one, the block never bound, so removing it + * changes no verdict, and failing the rewrite would fail a stored migration or + * a credential move over a key that does nothing. A row nothing rewrites keeps + * its bytes. + * + * `VALIDATION_ERROR` / 400, the envelope of the door's other content refusals + * (`savedItemNameRefusal`, the hook refusal above). No code is minted. It is + * located twice: the message names the key, and `issues[]` carries it as a + * path, the shape the Studio designer keys on. Every present value is refused, + * a malformed block included, because the block is not accepted here at all: + * judging its value first would ask an author to repair a block they then have + * to delete. The message prescribes first and explains second, because a 4xx + * message crosses the REST boundary bounded at 500 characters with its TAIL + * truncated. + * + * ⛔ The prescription names the package loader's door, never a `_lock*` key: + * those are the runtime's private envelope, never authored + * (`shared/protection.zod.ts`). + */ +function protectionBlockRefusal( + type: string, + item: unknown, + saveName: string, +): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return undefined; + if ((item as Record)[PROTECTION_BLOCK_KEY] === undefined) return undefined; + if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return undefined; + const err = new Error( + `Remove the \`protection\` block from ${type} '${saveName}' and send it again. Nothing was written. ` + + 'This door does not translate the block, so it would lock nothing: an item saved through the metadata API ' + + 'is authored in this system (ADR-0010 §3.5). To lock an item, declare `protection` on it in a code ' + + "package's source, where the package loader translates it into the item's lock (ADR-0010 Phase 4.3).", + ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = [{ + path: PROTECTION_BLOCK_KEY, + message: "Not accepted through the metadata API: only a code package's loader translates `protection` into a lock.", + code: 'custom', + }]; + return err; +} + +/** + * [ADR-0010 §3.5] The server-stated rewrites' half of + * {@link protectionBlockRefusal}: the same body without its `protection` block, + * on a type whose schema declares one. On any other type the body passes as + * it is, for the parse to judge. + * + * Returns the SAME reference when there is nothing to strip, and a copy + * otherwise, so a caller's own body is never mutated. + */ +function withoutProtectionBlock(type: string, item: unknown): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return item; + if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return item; + const rest = { ...(item as Record) }; + delete rest[PROTECTION_BLOCK_KEY]; + return rest; +} + +/** + * [#23165] The rows of an item a delete leaves stored at its address, in + * words, for the delete receipt: its rows bound to other packages (sorted, as + * {@link ObjectStackProtocolImplementation.packagelessAddress} answers them, + * at most five named) and its package-less row. The receipt names them + * instead of claiming the item gone or back at its artifact default. + */ +export function remainingRowsPhrase(packageIds: readonly string[], unbound: boolean, noun: 'row' | 'draft'): string { + const parts: string[] = []; + if (packageIds.length > 0) { + const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); + const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; + parts.push(packageIds.length === 1 + ? `its ${noun} bound to package ${shown}` + : `its ${noun}s bound to packages ${shown}${more}`); + } + if (unbound) parts.push(`its package-less ${noun}`); + const count = packageIds.length + (unbound ? 1 : 0); + return `${parts.join(' and ')} ${count === 1 ? 'remains' : 'remain'}`; +} + +/** + * [#23495, ADR-0048] The `404 NO_DRAFT` of a per-item publish that names no + * package, of an item bound to package `P` that holds no draft at `P`'s + * address ({@link ObjectStackProtocolImplementation.packagelessDraftAddress}) + * while drafts bound to other packages are stored. The code and status are the + * promotion's own, the ones the strict draft read naming no package answers + * in the same state; the sentence names the drafts that remain and the + * request that reaches them, prescription first (a 4xx message crosses the + * REST boundary bounded at 500 characters). + */ +function packagelessPublishNoDraftRefusal( + type: string, + name: string, + packageId: string, + otherPackageIds: readonly string[], +): Error & { code: 'NO_DRAFT'; status: 404 } { + const err = new Error( + 'Name the package of the draft to publish, then send it again: on the REST door add ?package= with the ' + + `package id, in process pass packageId. Nothing was published. ${type} '${name}' has no pending draft ` + + `bound to package '${packageId}', the package its published rows are bound to and the draft a publish ` + + `that names no package promotes, and ${remainingRowsPhrase(otherPackageIds, false, 'draft')}.`, + ) as Error & { code: 'NO_DRAFT'; status: 404 }; + err.code = 'NO_DRAFT'; + err.status = 404; + return err; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { packagelessPublishNoDraftRefusal, protectionBlockRefusal, withoutProtectionBlock }; diff --git a/packages/metadata-protocol/src/protocol-bundle-members.ts b/packages/metadata-protocol/src/protocol-bundle-members.ts new file mode 100644 index 00000000000..1bbff0a2b57 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-bundle-members.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the bundle- +// member selector, discriminator and mutation-event member, and the commit-plan item. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + overlayMemberOfBody, + overlayMemberValue, +} from './migrations/overlay-index.js'; +import { + // [#7774] The i18n-bundle identity table (#7730) and its reader. A type + // listed here is identified by `(name, )`, so every merge in the + // unscoped `/meta` list keys on the pair — see {@link mergePackageAwareOverlay}. + ITEM_KEY_DISCRIMINATORS, +} from '@objectstack/metadata-core'; +import { canonicalMetaType } from './protocol-meta-type-schema.js'; + +/** + * [#22877] The by-name doors' bundle member selector: the request member, and + * the `?member=` query parameter in each REST route's closed set (ruling + * 6116165299, Q2 i). Named after what the stored column holds + * (`sys_metadata.bundle_member`) and type-neutral, never `locale`: on `/meta`, + * `?locale=` is the response translation locale. + */ +export const BUNDLE_MEMBER_SELECTOR = 'member'; + +/** + * [#22877] The longest member a stored row can carry — the declared + * `maxLength` of `sys_metadata.bundle_member` (`@objectstack/metadata-core` + * `SysMetadataObject`, pinned equal in `protocol.bundle-member-selector.test.ts`). + * A longer selector names no member that could be stored, so it is refused as + * malformed rather than answered as an absent member. + */ +export const BUNDLE_MEMBER_MAX_LENGTH = 100; + +/** + * [#22877] THE one reading of the bundle member selector, shared by every + * by-name door (`getMetaItem`, `deleteMetaItem`, `publishMetaItem`, + * `historyMetaItem`, `rollbackMetaItem`). + * + * Returns the member as named, trimmed, or `undefined` when none was named, + * which every door reads as the CANONICAL member, as the registry's bare-name + * read does. Naming the canonical value (`en-US`) is the same request. A + * well-formed member with nothing stored or shipped is not refused here: each + * door answers it as it answers an absent item. + * + * Refuses, with a located 400 `VALIDATION_ERROR` — the code the REST door's + * recognition refusal answers for a misspelled selector on the same routes — + * a selector on a type whose identity is its name alone, and a value that + * cannot name a member: not a string, blank, or longer than + * {@link BUNDLE_MEMBER_MAX_LENGTH}. The message names the parameter. The REST + * door renders it through its one error door (`handleRouteError`), as it + * renders every refusal this protocol raises. ⛔ A blank selector is never read as + * absent: `?member=` asked for something, and answering the canonical member + * for it would be a guess. + */ +export function resolveBundleMemberSelector(type: string, member: unknown): string | undefined { + if (member === undefined) return undefined; + // Either spelling of the type: the doors ask after their boundary fold, + // and a direct caller may not have folded. + const singular = canonicalMetaType(type); + const disc = ITEM_KEY_DISCRIMINATORS[singular]; + const refuse = (sentence: string): never => { + const err = new Error(`The "${BUNDLE_MEMBER_SELECTOR}" selector ${sentence}`) as Error & { + code: 'VALIDATION_ERROR'; status: 400; param: string; + }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.param = BUNDLE_MEMBER_SELECTOR; + throw err; + }; + if (!disc) { + return refuse( + `names a bundle member, and a '${singular}' item has none: its identity is its name alone. ` + + `Remove the selector.`, + ); + } + const example = `'${disc.canonical}'`; + if (typeof member !== 'string') { + return refuse(`must be one string: the ${disc.field} of one '${singular}' member, e.g. ${example}.`); + } + const value = member.trim(); + if (value === '') { + return refuse( + `is blank. Name one '${singular}' member by its ${disc.field} (e.g. ${example}), or omit the ` + + `selector to address the canonical member (${example}).`, + ); + } + if (value.length > BUNDLE_MEMBER_MAX_LENGTH) { + return refuse( + `is ${value.length} characters long; a stored '${singular}' member holds at most ` + + `${BUNDLE_MEMBER_MAX_LENGTH}, so it names no member. Name one by its ${disc.field} (e.g. ${example}).`, + ); + } + return value; +} + +/** + * [#22877] The discriminator value of the member a selector addresses, for a + * registry call or a body comparison: the selector itself, or the canonical + * member when none was named. `undefined` for a type with no members. + */ +export function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { + const disc = ITEM_KEY_DISCRIMINATORS[canonicalMetaType(type)]; + if (!disc) return undefined; + return member?.trim() || disc.canonical; +} + +/** + * [#22877] `MetadataMutationEvent.member` for the member a write addressed, + * as a spread: empty for the canonical member and for a type with no members, + * so those events keep the shape they always had. + */ +function mutationEventMember(type: string, member: string | null | undefined): { member?: string } { + const value = overlayMemberValue(type, member); + return typeof value === 'string' ? { member: value } : {}; +} + +/** + * [#22877] One item of a package commit's revert plan (ADR-0067), as + * `sys_metadata_commit.items` stores it: `member` is the bundle member the + * item is, for a type whose identity is a pair, and is absent for the + * canonical member and every type with no members — so the plan of every + * other item is stored byte-identical to what it was. [#23073] The commit + * timeline serves it on every item of a bundled type, the canonical member + * spelled out ({@link ObjectStackProtocolImplementation.listCommits}). + */ +export type CommitPlanItem = { type: string; name: string; existedBefore: boolean; prevVersion: number | null; member?: string }; + +/** [#22877] The identity of a plan item or a draft within one batch: two members of one name are two. */ +function commitPlanKey(item: { type: string; name: string; member?: string }): string { + return `${item.type}/${item.name}\u0000${item.member ?? ''}`; +} + +/** + * [#22877] A repository call's `member` option as a spread: present only when a + * member was named, so a call addressing the canonical member — and every call + * for a type with no members — passes exactly the options it always did. + */ +export function memberOption(member: string | undefined): { member?: string } { + return member !== undefined ? { member } : {}; +} + +/** [#22877] {@link mutationEventMember} for the member a written BODY belongs to. */ +function mutationEventMemberOfBody(type: string, body: unknown): { member?: string } { + const value = overlayMemberOfBody(type, body); + return typeof value === 'string' ? { member: value } : {}; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { commitPlanKey, mutationEventMember, mutationEventMemberOfBody }; diff --git a/packages/metadata-protocol/src/protocol-client-failures.ts b/packages/metadata-protocol/src/protocol-client-failures.ts new file mode 100644 index 00000000000..ee6d5502c25 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-client-failures.ts @@ -0,0 +1,866 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the batch row +// results and row errors, the client-facing failure text and code, seed validation, and the +// metadata store and read failure errors. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types'; +import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; +import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { zodIssuesToMetadataIssues } from './protocol-metadata-issues.js'; +import type { MetadataIssueEntry } from './protocol-metadata-issues.js'; + +/** + * One row of a bulk-write result — exactly the `BatchOperationResult` the spec + * declares (`BatchOperationResultSchema`, spec/api/batch.zod.ts). Aliased to + * the spec type so tsc pins every batch loop in this file to the wire contract: + * declared = delivered (#4793; the conformance pin in + * protocol.batch-row-conformance.test.ts holds the runtime side). + * + * History: this was a deliberately divergent legacy shape (`error: string`, + * `record`, no `index`) that predated the schema; ADR-0119 D4 left it in place + * so a wire-visible change would not ride along on a bug fix, and #4793 is + * that tracked reconciliation, shipped in the v17 major window. The rollback + * marking (#4620) is structured with it: `ROLLED_BACK` / `NOT_ATTEMPTED` are + * `ApiError.code` values, not message-string prefixes. + */ +export type BatchDataRowResult = BatchOperationResult; + +/** + * Map a thrown per-row batch error into the wire's `ApiError` (#4793). + * + * `code` keeps the thrown error's own code when it is part of the declared + * vocabulary (StandardErrorCode ∪ ERROR_CODE_LEDGER — an invented code would + * make the row fail `BatchOperationResultSchema.parse`, exactly the drift the + * conformance pin exists to catch loudly rather than ship). Otherwise it + * derives from the HTTP status when the error carries one, falling back to + * INTERNAL_ERROR — an unclassified engine throw is a 500 in row form. + * + * `message` is {@link clientFacingRowFailureText}'s decision (#8502): a caught + * sentence reaches a caller only when its producer declared a client-facing + * refusal. `fallback` is what the row says otherwise — it names the operation + * and quotes nothing. + * + * ⛔ `fallback` is REQUIRED, not defaulted. All three catches that build a row + * know their operation, and a default would let a fourth one be added that + * silently reports the wrong verb. + * + * ## `httpStatus` reads the DECLARATION, not one spelling of it (#8570) + * + * This limb read `err.status` and nothing else, so of the producers measured at + * these catches — a real `ObjectQL` over a real `SqlDriver`, driven through all + * three bulk-write loops — two well-defined client refusals shipped a row with + * no status at all: + * + * | producer | code | `.status` | `.statusCode` | validation shape | was | is | + * |---|---|---|---|---|---|---| + * | {@link rowRequiredIdError} | VALIDATION_FAILED | **400** | — | no | 400 | 400 | + * | `recordNotFoundError` (`@objectstack/core`) | RECORD_NOT_FOUND | **404** | — | no | 404 | 404 | + * | objectql `ValidationError` | VALIDATION_FAILED | — | — | **yes** | — | **400** | + * | plugin-approvals' record lock | RECORD_LOCKED | — | **409** | no | — | **409** | + * | an app hook throwing a bare `Error` | — | — | — | no | — | — | + * | driver fault (`SqliteError`, …) | SQLITE_* | — | — | no | — | — | + * + * The first two are siblings of the last four *in the same response*: a caller + * branching on `httpStatus` to tell "fix your input" from "the server broke" + * got an answer for some failure rows and nothing for others, with no signal + * saying which. The single-spelling defect is #7525's, fixed there at the HTTP + * door; this is the same defect on the row. + * + * The question is answered by {@link resolveThrownHttpError}, IMPORTED — the + * `message` limb beside it already delegates there (#8502), and a second local + * chain would be the third derivation of "what status is this throw" in one + * function. ⛔ Do not re-spell it as `status ?? statusCode ?? validation`. + * + * ## ⛔ `declaredStatus`, never `status` — the over-broad direction is real + * + * That resolver answers for EVERY throw: its `status` is 500 for a bare hook + * `Error` and for a `SqliteError`, because 500 is the caller's fallback. + * Stamping that would put `httpStatus: 500` on the last two rows of the table, + * which never carried one — an ADDITION to the wire for those populations, and + * a claim the producer never made. `declaredStatus` is the same resolution + * minus the fallback: present exactly when the throw declared a status in one + * of the three spellings, absent otherwise. So a declared refusal gains the + * status it always meant, and an undeclared fault keeps carrying none. + * + * The gate is DECLARED-ness and deliberately not the 4xx band that + * {@link clientFacingRowFailureText} uses. That limb decides disclosure of free + * text, where a 5xx must be withheld; this one decides a number the producer + * itself authored, and a row already ships `httpStatus: 503` today when the + * refusal spells `.status` — narrowing to 4xx would WITHDRAW a status the wire + * carries, which is a different decision from this one. + * + * Both limbs read the same resolution for a second reason: they must agree. + * Deriving `code` from `err.status` while `httpStatus` came from + * `declaredStatus` would mint incoherent rows — `{ code: 'INTERNAL_ERROR', + * httpStatus: 409 }` for a `statusCode`-spelled refusal whose own code the + * ledger does not know. + * + * ## One wire spelling for a unique-constraint refusal (commit 65846bc46) + * + * The engine answers a driver's unique-constraint refusal with its + * `DuplicateRecordError` envelope — `code: 'DUPLICATE_RECORD'`, `status: 409`, + * the driver's error whole on `cause`. `DUPLICATE_RECORD` is a registered + * member, so the verbatim limb above used to put it on the row as-is, while + * every WHOLE-REQUEST door in `@objectstack/rest` answers the same class as + * `UNIQUE_VIOLATION` — the standard-catalog member the published protocol + * docs give for the 409 constraint-violation body. After commit 6d178a408 the two + * spellings sat side by side in one route's responses: a whole-request + * failure on `POST /data/:object/batch` said `UNIQUE_VIOLATION`, a row on the + * same route said `DUPLICATE_RECORD`. + * + * Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire spelling on every route, + * `UNIQUE_VIOLATION`; the row reports it too. So this limb maps the engine's + * envelope to the wire spelling BEFORE the verbatim registered-code rule — + * keyed exactly as the whole-request arm keys it (`error-response.ts`'s + * `structuredCodeAnswer`: the registered code AND the class name), never by + * message text. `httpStatus` still reads the declared 409 and `message` is + * still the envelope's platform sentence (a declared 4xx, so it is quoted); the + * driver's text stays on `cause`, which never reaches the row. + * + * ⛔ The engine's thrown identity is NOT renamed: `DuplicateRecordError.code` + * stays `DUPLICATE_RECORD` (an in-process caller — a hook, a flow node — + * branches on that). What moves is what a ROW REPORTS across the HTTP + * boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook + * throwing the registered code without being the engine's class — keeps its + * own code on the row, the same discrimination the whole-request arm makes. + * + * ## [#24561] A sandboxed body's refusal is not a server fault in row form + * + * The `INTERNAL_ERROR` default assumes an error that declared nothing is a + * fault. A sandboxed hook body's bare `throw new Error('…')` declares no status + * or code either, yet every data door (`/data`, `/createMany`, `/import`) + * serves it as a `400` refusal in the body's words, so a batch row said + * `INTERNAL_ERROR` beside the doors' `VALIDATION_ERROR` for the same refusal. + * {@link sandboxRefusalSentence} is the one test both limbs ask: when it + * answers, the `code` limb derives from the `400` the doors serve (the + * `declared ?? 400` of `classifyDataError`, `@objectstack/types`). A code or + * status the body DID declare still wins, as for every other producer. + * + * ⛔ `httpStatus` is NOT stamped for it. That limb carries only a status the + * producer declared (above), and this body declared none. + */ +export function toRowApiError(err: any, fallback: string): ApiError { + const thrown = isEngineDuplicateRecordEnvelope(err) + ? 'UNIQUE_VIOLATION' + : typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success + ? (err.code as ApiError['code']) + : undefined; + const { declaredStatus } = resolveThrownHttpError(err); + const servedStatus = declaredStatus ?? (sandboxRefusalSentence(err) !== undefined ? 400 : undefined); + return { + code: thrown ?? (servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus) : 'INTERNAL_ERROR'), + message: clientFacingRowFailureText(err, fallback), + ...(declaredStatus !== undefined ? { httpStatus: declaredStatus } : {}), + }; +} + +/** + * [commit 65846bc46] Is this thrown value the ENGINE's unique-violation envelope? + * + * The same two-part gate `@objectstack/rest`'s whole-request arm applies — + * the registered code AND the class name — so a batch row and a whole-request + * failure recognise the engine's envelope by one rule. Name alone would miss + * nothing today but would let a future class of the same name speak a + * different code; code alone would swallow a hook that deliberately throws + * the registered `DUPLICATE_RECORD` from its own body, which is a different + * producer speaking a member of the vocabulary and keeps its own spelling. + * The class itself is not imported: this package does not depend on + * `@objectstack/objectql`, and the envelope's contract is its declared + * `code` / `name` / `status`, not its prototype. + */ +function isEngineDuplicateRecordEnvelope(err: unknown): boolean { + const e = err as { code?: unknown; name?: unknown } | null | undefined; + return e?.code === 'DUPLICATE_RECORD' && e?.name === 'DuplicateRecordError'; +} + +/** + * [#7426] Carry a wrapped error's `code` onto its re-wrap — but only when that + * code is part of the DECLARED vocabulary. + * + * ## What it fixes + * + * {@link ObjectStackProtocolImplementation.deleteMetaItem} is the one verb in + * this file that re-wraps rather than rethrows: its two catches build a fresh + * `Error` carrying the "failed to delete" context, and they carried `status` + * forward while dropping `code`. Every sibling verb translates `ConflictError` + * and then `throw err`s the original untouched, so `code` survives there. The + * consequence was topology-dependent: a `SysMetadataRepository` refusal — the + * only refusal a CONTROL-PLANE kernel can produce for these types, since + * `deleteMetaItem`'s own two-tier block is skipped when `environmentId` is + * undefined — reached the caller as **403 with `code: undefined`**, its code + * surviving only as prose inside the message, while the identical refusal on a + * project kernel arrived with `NOT_OVERRIDABLE` intact. ADR-0112 makes the code + * the machine-readable half of a refusal; a 403 without one is what the ledger + * exists to prevent. + * + * ## Why it is a PREDICATE and not `e.code = err.code` + * + * That re-wrap is the exit of every non-conflict failure on the path — genuine + * driver faults as much as refusals — so an unconditional copy would put a + * driver's own dialect (`42P01`, `SQLITE_CONSTRAINT`, `ECONNREFUSED`) into the + * field `ApiErrorSchema.code` declares as a closed union (ADR-0112 D4), which + * is the drift the ledger exists to stop. The gate is therefore membership in + * `StandardErrorCode ∪ ERROR_CODE_LEDGER` — verbatim the predicate + * {@link toRowApiError} above already applies to decide which thrown code may + * become a wire code. One rule, one place to change it, and a code the engine + * DID register (`ERR_DATASOURCE_UNAVAILABLE`) is as welcome as one this package + * threw. + * + * ## What it deliberately does not touch + * + * `status`. The repository catch's `err?.status ?? 500` and the legacy catch's + * literal `500` are unchanged — the second is a path with no authorization gate + * on it, where every failure really is a fault, and moving either is a separate + * contract decision. Only the `code` rule is unified across the two exits, so + * the envelope does not vary by which failure kind produced it. + */ +function carryCatalogedErrorCode(target: Error, source: unknown): void { + const code = (source as { code?: unknown } | null | undefined)?.code; + if (typeof code === 'string' && ErrorCode.safeParse(code).success) { + (target as Error & { code?: string }).code = code; + } +} + +/** + * [#8136] Whether a caught error **declared itself a client-facing refusal** — + * a 4xx `status` in the ADR-0112 envelope — and its sentence may therefore be + * quoted back to the caller. + * + * ## The rule this answers, and why it is a POSITIVE list + * + * This package used to interpolate whatever it caught into the message it + * threw (`Failed to delete customization overlay: ${err.message}`) and into the + * `error` strings it collects for a caller. A driver failure on `sys_metadata` + * therefore reached a client verbatim — `SQLITE_ERROR: no such table: + * sys_metadata` on a 500, and the same text inside `failed[].error` on the + * `PACKAGE_DELETE_PARTIAL` 400, where no message-level withhold at any HTTP + * boundary can reach it because it is not the message. Three downstream + * sanitizers each had a hole because of it. + * + * The cure is Prime Directive #12's: pay the consumer-side tolerance down at + * the producer. So the question asked here is **not** "does this text look like + * a driver dump?" — that is `looksLikeInternalErrorLeak`, a heuristic over + * phrasing, and a phrasing test can only ever know the dialects someone has + * met. It is the inverse and bounded question: **did we author this sentence + * for a caller?** A producer that declared 4xx has said the failure is the + * caller's to fix and has written the remedy into the message — the + * self-correcting refusals `SysMetadataRepository` raises (`ITEM_LOCKED`, + * `WRITABLE_PACKAGE_REQUIRED`, `NO_DRAFT`, …) are exactly that, and they + * must survive intact. Everything else is withheld by DEFAULT, so a dialect + * this repo has never run is handled correctly without anyone having enumerated + * it. + * + * ## ⛔ NOT the complement of `declaresServerFault` + * + * The obvious-looking `!declaresServerFault(err)` (`@objectstack/types`, #5811) + * is the wrong direction and would reinstate the whole defect: a bare `Error` + * from a driver declares NOTHING, so it fails that test and would be quoted — + * and a bare driver `Error` is precisely the case measured here. The two + * predicates answer different halves and neither is the other's negation: + * `declaresServerFault` asks a BOUNDARY whether to withhold a declared fault's + * detail; this asks a PRODUCER whether it is allowed to quote at all, and an + * undeclared error is never allowed. + * + * ## Why `status` alone, when the sibling code rule also checks the catalog + * + * {@link carryCatalogedErrorCode} gates on membership in `StandardErrorCode ∪ + * ERROR_CODE_LEDGER` because it writes `ApiErrorSchema.code`, a closed union a + * driver's own dialect must never enter. A message is free text, so the + * catalog does not bound it, and the two readings coincide on every refusal + * that reaches these exits today (each declares both halves). Where they could + * differ, status-alone is the safe direction: requiring a catalogued code too + * would blank an authored refusal that happens to carry an uncatalogued one — + * deleting a #4277 self-correcting message, which is a usability regression in + * exchange for no disclosure gain. + * + * The withheld text never leaves the server: every call site rides the original + * error on `cause`, which `handleRouteError` / `logWithheldServerFault` print + * whole — the same posture {@link metadataStoreUnavailableError} already takes. + */ +export function declaresClientRefusal(err: unknown): boolean { + const status = (err as { status?: unknown } | null | undefined)?.status; + return typeof status === 'number' && status >= 400 && status < 500; +} + +/** + * [#8136] The client-facing sentence for a failed overlay delete: the caller's + * own refusal when they declared one, and otherwise a stable line that names + * the operation and quotes nothing. + * + * Both of {@link ObjectStackProtocolImplementation.deleteMetaItem}'s re-wrap + * exits share it, for the reason {@link carryCatalogedErrorCode} gives about + * `code`: the envelope must not vary by which path served the delete. + * + * The `Failed to delete customization overlay` prefix is unchanged, byte for + * byte — it is the operation description a caller needs and several pins read + * it. What changes is what follows the colon. + */ +function overlayDeleteFailureMessage(err: unknown, type: string, name: string): string { + if (declaresClientRefusal(err)) { + const declared = (err as { message?: unknown } | null | undefined)?.message; + if (typeof declared === 'string' && declared.length > 0) { + return `Failed to delete customization overlay: ${declared}`; + } + } + return `Failed to delete customization overlay for ${type}/${name}. ` + + 'The metadata store rejected the delete; the reason is in the server log. ' + + 'Retry once the metadata database is reachable.'; +} + +/** + * [#8136] The per-item `error` string a collector puts on its response — the + * caller's own refusal when they declared one, otherwise a stable fallback. + * + * The counterpart of {@link overlayDeleteFailureMessage} for the paths that + * report failure as DATA rather than by throwing. That distinction is why the + * producer is the only place this can be fixed: `deletePackage`'s `failed[]` + * and `cleanups[]` ride onto a `PACKAGE_DELETE_PARTIAL` 400 inside `details`, + * so no 5xx message withhold at any HTTP boundary ever sees them. + * + * @param fallback - what to say when nothing may be quoted. Already the + * existing no-message fallback at every call site (`'delete failed'`, + * `'cleanup failed'`), so the withheld case reuses the sentence the caller + * could already receive rather than inventing a second vocabulary. + * + * [#8443] EXPORTED (via `index.ts`) for the same reason `recordNotFoundError` + * is: `@objectstack/runtime`'s package-publish door carries a seed-apply + * fallback for protocols that do not self-apply, and it reports failure as + * data on the same `seedApplied` field this package's `applySeedBodies` does. + * A second private restatement of the rule over there is exactly how the rule + * drifts out of sync — the withhold and the quote must be ONE decision, in one + * place, for both producers. `declaresClientRefusal` stays private on purpose: + * nothing outside this file needs the raw predicate, and an exported surface + * with no consumer is the "declared but nobody pulls it" shape AGENTS.md + * treats as debt. + */ +export function clientFacingFailureText(err: unknown, fallback: string): string { + if (declaresClientRefusal(err)) { + const declared = (err as { message?: unknown } | null | undefined)?.message; + if (typeof declared === 'string' && declared.length > 0) return declared; + } + return fallback; +} + +/** + * [#24561] The sentence a sandboxed hook body's business refusal says on a + * batch row, or `undefined` when this error is not one. + * + * The read is `sandboxBusinessMessage` (`@objectstack/types`), the one every + * sibling door makes (the import runner, `/data`, `/createMany`), never a + * pattern-strip of the wrapper off `.message`. It already declines a body + * that CRASHED and the sandbox's own fault. The one condition added here is + * the doors' own: a refusal that declared a server-band status is answered as + * a fault with its text withheld (`classifyDataError`'s `declared < 500`, read + * through the same `declaredHttpStatus`), so it is not a sentence for a row + * either. + * + * Both limbs of {@link toRowApiError} ask this, so the row's `code` and + * `message` cannot disagree about whether a refusal was disclosed. + */ +function sandboxRefusalSentence(err: unknown): string | undefined { + const sentence = sandboxBusinessMessage(err); + if (sentence === undefined) return undefined; + const declared = declaredHttpStatus(err); + return declared === undefined || declared < 500 ? sentence : undefined; +} + +/** + * [#8502] The per-row `errors[].message` a bulk data write puts on its + * response — the producer's own refusal when it declared one, otherwise a + * stable sentence naming the operation. + * + * The third sink in this family, after #8333's `failed[].error` and #8442's + * seed `errors[].message`, and the same defect: `toRowApiError` interpolated + * `err.message` unconditionally, so a driver fault under `deleteManyData` + * answered `{ code: 'INTERNAL_ERROR', message: 'SQLITE_ERROR: no such table: + * leave_request' }` on a per-row result riding a **200**. No HTTP boundary's + * 5xx withhold reaches it, because it is not the response's message — it is + * response DATA. It also multiplies: `reconcileStoppedBatch` and + * `buildRolledBackBatchResponse` interpolate the causal row's message into + * every `NOT_ATTEMPTED` / `ROLLED_BACK` sibling, so one leaked sentence is + * repeated across the batch. + * + * ## Why this asks the BOUNDARY RESOLVER rather than {@link declaresClientRefusal} + * + * The question is #8136's, unchanged: **did a producer author this sentence + * for a caller?** What differs is the population that answers it. This sink + * sits under `engine.insert` / `update` / `delete`, so it receives every + * refusal the data path can raise — and MEASURED on the real stack (a real + * `ObjectQL` over a real `SqlDriver`, driven through all three loops), that + * population declares itself in THREE spellings, only one of which + * `declaresClientRefusal` reads: + * + * | producer | code | `status` | `statusCode` | validation shape | + * |---|---|---|---|---| + * | {@link rowRequiredIdError} | VALIDATION_FAILED | **400** | — | no | + * | `recordNotFoundError` (`@objectstack/core`) | RECORD_NOT_FOUND | **404** | — | no | + * | objectql `ValidationError` | VALIDATION_FAILED | — | — | **yes** | + * | plugin-approvals' record lock | RECORD_LOCKED | — | **409** | no | + * | an app hook throwing a bare `Error` | — | — | — | no | + * | driver fault (`SqliteError`, …) | SQLITE_* | — | — | no | + * + * The first two are the only ones a `status`-only test admits. The third is + * the population #8442 met on the seed channel: an objectql `ValidationError` + * carries `code: 'VALIDATION_FAILED'` and deliberately NO `status`, because + * per `@objectstack/types`' `validation-failure.ts` deciding it means 400 is + * "the job of whichever boundary serves it". The fourth is this sink's own, + * and neither sibling card met it: `plugin-approvals` binds a GLOBAL + * `beforeUpdate` hook whose `lockedError` throws `statusCode` — the spelling + * `resolveThrownHttpError` reads "because both are produced in this repo", + * after reading only one of them made `/api/v1/data` answer 500 to a + * deliberate `409 RECORD_LOCKED` (#7525). + * + * So the test is not "does it carry `status` 4xx" but **"would the boundary + * that serves this throw call it a client refusal?"** — and that question + * already has exactly one implementation, {@link resolveThrownHttpError}, + * which folds all three declarations into one status. It is IMPORTED, never + * re-spelled: a fourth local spelling of "this is a 4xx" is how a batch row + * and the single-record `PATCH` of the same object would start disagreeing + * about the same error. ⛔ Do not replace this with a hand-written + * `status ?? statusCode ?? validation` chain for being cheaper to read. + * + * ## ⛔ Still a POSITIVE list — the undeclared case is withheld + * + * A hook that throws a bare `Error` has its sentence withheld, and that is the + * measured cost of the rule, not an oversight: at this sink an undeclared hook + * throw is indistinguishable from an undeclared driver throw, which is the + * whole hole. The remedy is at the producer and now has three accepted + * spellings — a 4xx `status`, a 4xx `statusCode`, or the `VALIDATION_FAILED` + * shape `validationFailure()` builds — so declaring is cheaper than the + * workaround, which is the direction that makes authored code hard to get + * wrong. The same reasoning #8333 applied when it fixed P9's undeclared + * `ZodError` at the producer rather than loosening the collector. + * + * ## The operator half + * + * The withheld sentence never leaves the server: it is logged here, at the one + * point where the decision is made, so no call site can withhold without + * logging. `console.warn` and not `error` deliberately — nothing claimed to be + * persisted was silently dropped (the row reports `success: false` and the + * counters reconcile), which is the AGENTS.md judgment question the durability + * levels turn on. + * + * ## [commit 93d2d679b] …and the DISCLOSED row deliberately logs NOTHING + * + * Since #14095 a driver unique violation arrives here already wrapped in the + * engine's `DUPLICATE_RECORD` envelope, which declares `status: 409` — so the + * row is disclosed and this function returns before the `console.warn` above. + * That was filed as a possibly LOST diagnostic: withholding used to be what + * carried the driver's own sentence to an operator, and disclosure removes + * that carrier. + * + * Measured on the real stack rather than reasoned about — a real `SqlDriver` + * over better-sqlite3 through this very sink, in `@objectstack/runtime`'s + * `batch-row-driver-text-real-driver.integration.test.ts` — the sentence is + * NOT lost: the engine's own insert door logs the envelope's `cause` + * (#14095 / commit 9d7f7259f, `e instanceof DuplicateRecordError ? e.cause : e`, + * because the platform logger serializes only `message` and `stack`), so + * `UNIQUE constraint failed: bd_note.email` is in the server log with the + * failing column intact. The diagnostic moved one hop; it was not deleted. + * + * ⛔ So do NOT add a log line to the disclosed branch. It would restate what + * the engine already logged, once per duplicate row of a batch, at a site + * where the failure was handed to the CALLER — the third answer AGENTS.md's + * degradation rule names, which is "not a degradation at all". The invariant + * this function owes is one-directional and is pinned in BOTH directions by + * that integration file: it logs when it WITHHOLDS, and is silent when it + * does not. + * + * ## [#24561] A sandboxed body's refusal is read the way every sibling door reads it + * + * The undeclared hook above is an IN-PROCESS hook. A sandboxed (QuickJS) hook + * body is a different producer: its runner puts the body's own sentence on + * `SandboxError.innerMessage` for the caller and keeps the + * `hook '' threw: …` debug wrapper on `.message` for the log. `/data`, + * `/createMany` and `/import` answer that sentence; this sink withheld it as a + * fault, so `POST /data/:object/batch` alone said "the reason is in the server + * log" for a refusal the hook wrote for the caller. The sentence is now read + * through {@link sandboxRefusalSentence} FIRST, which is + * `sandboxBusinessMessage` (`@objectstack/types`), the import runner's read. + * + * And a sandbox origin's `.message` is never quoted, whatever it declared. + * It is the wrapper, written for the log: a refusal that declared a 4xx used + * to be quoted from there, wrapper and all, and so did a body that CRASHED + * with a declared 4xx, which every data door answers `500` with its text + * withheld. (The runner's own faults never declare a status, so the 4xx + * branch cannot reach them.) What stays withheld is what the doors withhold: + * a crash, the runner's own fault, a refusal that declared a 5xx, and every + * non-sandbox error that declared no 4xx. + */ +function clientFacingRowFailureText(err: unknown, fallback: string): string { + const sentence = sandboxRefusalSentence(err); + if (sentence !== undefined) return sentence; + // 500 as the fallback status: an error that declared nothing is a server + // fault in row form, exactly what `toRowApiError`'s own code limb assumes. + const { status } = resolveThrownHttpError(err, 500); + if (status >= 400 && status < 500 && !isSandboxOrigin(err)) { + const declared = (err as { message?: unknown } | null | undefined)?.message; + if (typeof declared === 'string' && declared.length > 0) return declared; + } + console.warn( + '[Protocol] Withheld a caught error\'s text from a batch row: the producer declared no ' + + 'client-facing refusal (no 4xx status or statusCode, not the VALIDATION_FAILED shape, and not a ' + + 'sandboxed body\'s business refusal), so its ' + + 'sentence must not be quoted back on response data. The row says: ' + + `"${fallback}" — cause (withheld from the response):`, + err, + ); + return fallback; +} + +/** + * [#8441] The client-facing `code` for a failed row on a batch verb's + * `failed[]` — the caught error's own code when the CATALOG declares it, and + * otherwise a catalogued stand-in. + * + * ## The measurement + * + * The sibling of {@link clientFacingFailureText}, one field over, and it needs + * a DIFFERENT rule. #8333 closed the `error` limb at these same two collectors + * and left this one explicitly alone. Measured afterwards — on the fixed branch, + * with the message withhold already in place — `publishPackageDrafts` still + * answered `{ error: 'publish failed', code: 'SQLITE_ERROR' }`: the sentence + * withheld and the driver's own dialect still shipping beside it, on response + * DATA that no HTTP boundary's withhold can reach. `revertCommit` measured the + * same, `{ error: 'revert failed', code: 'SQLITE_ERROR' }`. + * + * ## ⛔ NOT {@link declaresClientRefusal}'s question + * + * That predicate asks whether a producer AUTHORED this sentence for a caller, + * because a message is free text and no catalog bounds it. This field is + * `ApiErrorSchema.code`: a CLOSED union (ADR-0112 D4) whose membership test + * this file already has — {@link carryCatalogedErrorCode}'s, itself verbatim + * {@link toRowApiError}'s. So the question here is **"is this code in the + * catalog?"**, and asking the 4xx question instead would be the wrong test on + * the wrong field twice over: it would admit `SQLITE_ERROR` from any producer + * that happened to declare a 4xx, and it would reject + * `ERR_DATASOURCE_UNAVAILABLE` — a code the ledger registered. + * + * ## Why it SUBSTITUTES rather than drops the limb + * + * `code` is the half a client BRANCHES on. `BATCH_ABORTED` rides the same + * array, and the Studio publish form highlights the offending field from + * `INVALID_METADATA` plus the structured `issues` beside it. Deleting the limb + * would trade a real authoring surface for a disclosure narrower than the one + * #8333 already closed — a worse deal than doing nothing. So a catalogued code + * passes through untouched, and only an uncatalogued one is replaced: by the + * status-derived standard code when the error declared a status, otherwise + * `INTERNAL_ERROR`. That derivation is {@link toRowApiError}'s, reused rather + * than restated, so there is one rule and one place to change it. ⛔ No code is + * minted here — ADR-0114's catalog already names every value this returns. + * + * ## What it deliberately does not touch + * + * An error carrying **no** code still produces **no** limb: `...(code ? …)` is + * the shape both call sites had, and it is preserved. Minting a code where the + * wire never carried one is an ADDITION to the response — a separate contract + * decision, and the same posture {@link carryCatalogedErrorCode} takes toward + * `status`. `issues` is unchanged for the same reason it was out of #8333's + * scope: it is structured authoring feedback, and no driver error carries it. + * + * The other two `failed[]` collectors that build this limb — + * `discardPackageDrafts` and `deletePackage` — need no filter and got none. + * Measured under the same failing driver: their rows carry no `code` at all. + * Both wrap exactly one call, {@link + * ObjectStackProtocolImplementation.deleteMetaItem}, whose two re-wrap exits + * ALREADY run {@link carryCatalogedErrorCode}, so an uncatalogued driver code + * never survives to them. Adding a second filter there would be consumer-side + * tolerance over a producer that does not need it (Prime Directive #12) — + * the same call #8136 recorded at those sites for the message. + */ +export function clientFacingFailureCode(err: unknown): string | undefined { + const code = (err as { code?: unknown } | null | undefined)?.code; + if (!code) return undefined; + if (typeof code === 'string' && ErrorCode.safeParse(code).success) return code; + const status = (err as { status?: unknown } | null | undefined)?.status; + return typeof status === 'number' ? standardErrorCodeForHttpStatus(status) : 'INTERNAL_ERROR'; +} + +/** + * [#8333] The seed-request schema rejection, DECLARED — a 422 refusal with the + * same envelope `saveMetaItem` raises for a failed overlay parse. + * + * ## Why this exists rather than a wider rule at the collector + * + * {@link ObjectStackProtocolImplementation.applySeedBodies} reports failure as + * DATA (`seedApplied.error`), and #8136's rule quotes a caught sentence only + * when the error DECLARED itself a client-facing refusal. Measured on + * `origin/main`, that catch receives two populations: + * + * 1. **driver text** — a `sys_metadata` read failing underneath the loader's + * dependency-graph walk arrives verbatim, and `seedApplied` rides on a + * `200` publish response, so no HTTP boundary can withhold it; + * 2. **the seed bodies' own schema rejection** — `SeedLoaderRequestSchema` + * used to `parse()`, so a malformed seed reached the same catch as a raw + * `ZodError`. That is AUTHORING feedback and it must survive — but it + * declared nothing, so the #8136 rule would have blanked it. + * + * ⛔ The wrong cure is loosening the rule at the collector: that turns a + * positive list into a guess and re-admits population 1. The right one is the + * direction #8136 set — **declare the refusal at its own producer**. So the + * parse becomes a `safeParse` and its rejection is minted here as a real + * ADR-0112 envelope (422 / `INVALID_METADATA` / structured `issues`), which + * then satisfies {@link declaresClientRefusal} on its own merits and is quoted + * back to the author unchanged by a collector that needed no special case. + * + * The author is also strictly better off: the old path stringified a whole + * `ZodError`, so `seedApplied.error` was a multi-line JSON dump of raw zod + * internals. The curated findings {@link zodIssuesToMetadataIssues} produces + * for every other authoring surface ride this error's `issues` — surfaced on + * `seedApplied.issues` by the catches (#10524) — and the message is their + * one-sentence headline. + * + * [#8443] EXPORTED alongside {@link clientFacingFailureText}: the runtime + * package-publish door parses the SAME `SeedLoaderRequestSchema` in its own + * seed-apply fallback and hits the identical two-population catch, so it needs + * the identical declaration — same sentence, same `INVALID_METADATA`/422, same + * curated `issues`. Minting a second 422 over there would give one authoring + * mistake two different envelopes depending on which protocol served the + * publish. + */ +export function seedRequestValidationError(zodIssues: unknown): Error { + const issues = zodIssuesToMetadataIssues(zodIssues); + // [#10524] `message` is a HEADLINE — count plus `path [zod code]` + // locators — never a restatement of the issue prose: the same `issues` + // array rides the error structurally, and the catches that surface this + // refusal thread it onto `seedApplied.issues` beside the headline, so + // the author's curated per-key prose still arrives exactly once. + const err = new Error( + `The published seed bodies failed spec validation: ` + + metadataIssueHeadline(issues), + ); + (err as any).code = 'INVALID_METADATA'; + (err as any).status = 422; + (err as any).issues = issues; + return err; +} + +/** + * [#10524] The one-sentence headline for a refusal whose per-path detail + * rides `issues[]` structurally: total count plus up to three + * `path [zod code]` locators. Restating the issue MESSAGES here is exactly + * the duplication #10524 removed — every console rendering both channels + * showed each finding twice — so the message names WHERE and HOW MANY and + * leaves the prose to the structured channel. The leading count subsumes the + * old `(+N more)` tail. The author-time gate composes its own analogue with + * `[rule]` locators (`runtime-authoring-gate.ts`), deliberately: rule ids + * and zod codes are different vocabularies and folding them into one helper + * would blur which one a reader is looking at. + */ +function metadataIssueHeadline(issues: MetadataIssueEntry[]): string { + const locators = issues.slice(0, 3) + .map((i) => `${i.path || ''}${i.code ? ` [${i.code}]` : ''}`) + .join('; '); + return `${issues.length} issue${issues.length === 1 ? '' : 's'} — ${locators}`; +} + +/** + * A batch row that names no record id for an operation that needs one — a + * caller error, so it carries VALIDATION_FAILED / 400 rather than falling + * through {@link toRowApiError}'s unclassified-throw default (#4793). + */ +export function rowRequiredIdError(operation: 'update' | 'delete'): Error { + const err = new Error(`Record id is required for ${operation}`) as Error & { code?: string; status?: number }; + err.code = 'VALIDATION_FAILED'; + err.status = 400; + return err; +} + +/** + * [#5532] The client-facing sentence for "the `sys_metadata` overlay read + * failed, so I do not know whether this item exists". + * + * Deliberately does NOT interpolate the driver's own message. #5437 records + * why: the REST boundary drops a 5xx's prose unconditionally, and the two + * write-side 500s in this very file (`Failed to persist customization overlay + * to sys_metadata: ${dbError.message}`) are the specimen that made it drop — + * a driver line is nowhere near the length bound, so it arrived intact. The + * driver error still reaches the operator: it rides as `cause` on the thrown + * error, and `handleRouteError` / `logWithheldServerFault` print the whole + * object. + */ +const METADATA_STORE_UNAVAILABLE_MESSAGE = + 'The metadata store could not be read, so whether this item exists is unknown. ' + + 'Retry once the metadata database is reachable.'; + +/** + * [#5532] A `sys_metadata` READ that failed for a reason that is NOT "the table + * has not been provisioned yet" — i.e. the rows may well exist and simply were + * not seen. + * + * 503, not 500: nothing about the REQUEST is wrong, the condition is a + * dependency outage that may clear, and a caller/proxy SHOULD retry. That is + * the same verdict `mapDataError` already gives `ERR_DATASOURCE_UNAVAILABLE`. + * `SERVICE_UNAVAILABLE` is the standard catalog's own code for 503 + * (`HttpStatusErrorCodeMap[503]`, ADR-0112) — a catalogued code rather than an + * invented string, and no new ledger vocabulary for a distinction no consumer + * measures today. + */ +export function metadataStoreUnavailableError(cause: unknown): Error { + const err = new Error(METADATA_STORE_UNAVAILABLE_MESSAGE) as Error & { + code?: string; + status?: number; + cause?: unknown; + }; + err.code = 'SERVICE_UNAVAILABLE'; + err.status = 503; + err.cause = cause; + return err; +} + +/** + * [#12536] The producer-side CLASSIFICATION every `sys_metadata` read failure + * now passes through — the one door, so that "is this a store fault or an + * application refusal?" is answered once instead of per call site. + * + * ## The defect it closes + * + * A metadata app's sandboxed hook on `sys_metadata` may REFUSE a read and mark + * its refusal with `userMessage` — the producer-side opt-in commit 79c46da90 added, where the + * field's PRESENCE is the marking (maintainer ruling, 2026-08-19, objectui#5210 + * option 1). Every such refusal used to be handed straight to {@link + * metadataStoreUnavailableError}, which builds a FRESH error carrying only + * `code` / `status` / `cause`. `declaredUserMessage` reads the TOP level and + * never `cause`, so the mark died at the producer: measured on `origin/main`, + * a marked `beforeFind` refusal and a `connect ECONNREFUSED` came out of + * `getMetaItems` as the same `503 SERVICE_UNAVAILABLE` with the same sentence + * and `declaredUserMessage === undefined` on both. A hook author's localized + * refusal could not reach an end user through this limb at all, and the + * console substituted its #3821 generic string. + * + * ## The ruling this implements + * + * Maintainer, 2026-08-27 (verbatim: 「同意」), option B: a producer-marked + * application refusal must NOT be wrapped as a store-unavailable error in the + * first place. It travels its OWN refusal category with the author's text + * intact; a genuine store fault keeps #8136's deliberately non-quoting 503, + * untouched. Both prior rulings stand — they stop colliding here. + * + * ⛔ The declined shortcut, recorded so nobody re-implements it: forwarding the + * mark ACROSS the 503 (a `cause` fall-through in `declaredUserMessage`, or a + * `userMessage` copied onto the store-unavailable error). That would open a + * quoting exception in the one door #8136 built to quote nothing, and the + * typical failure of that shape is passing the whole cause chain through — + * a silent leak. Nothing from `cause` crosses the 503 door: this function + * either builds the refusal INSTEAD of the 503, or leaves the 503 exactly as + * it was. + */ +export function metadataReadFailureError(cause: unknown): Error { + // {@link declaredUserMessage} is THE read — "never with an inline `typeof` + // probe" is that function's own instruction, and it is why a blank or + // non-string `userMessage` is not a declaration here either. + const marked = declaredUserMessage(cause); + if (marked !== undefined) return markedApplicationRefusalError(cause, marked); + return metadataStoreUnavailableError(cause); +} + +/** + * [#12536] The refusal category a producer-MARKED failure travels in, built so + * the author's text survives verbatim and nothing else from the cause does. + * + * ## What is quoted, and what is not + * + * `message` is the marked text itself. That is the one string the producer + * declared is addressed to the end user (commit 79c46da90), so quoting it discloses + * nothing that was not authored for a caller — and it is the ONLY thing taken + * from the cause. The cause's own `message` is a diagnostic and is NOT read: + * it rides on `cause`, which `handleRouteError` / `logWithheldServerFault` + * print whole for the operator, the same posture {@link + * metadataStoreUnavailableError} takes. + * + * ## Status and code are RESOLVED, never re-spelled + * + * `resolveThrownHttpError` is imported for both, for the reason {@link + * clientFacingRowFailureText} gives: a fourth local spelling of "what did this + * throw declare?" is how two seams start disagreeing about one error. It folds + * `status`, `statusCode` and the validation shape into one status, and gives + * the code rule this file already applies elsewhere — a catalogued `.code` + * passes through, anything else demotes to the status-derived standard code, + * so `ApiErrorSchema`'s closed union (ADR-0112 D4) can never receive a + * driver's dialect. + * + * The fallback status is 400, and it is NOT invented here: it is what the REST + * sandbox door already answers for a hook refusal that named no status of its + * own (`error-response.ts`, commit 8f266f1cd — `declared ?? 400`, pinned by + * `hook-error-format.dogfood.test.ts`). A hook that DID name one keeps it + * (#7867: "an error that NAMES its own HTTP status is asking to be served with + * it"), which is also why this is status-agnostic in the way the `userMessage` + * contract requires — a 400, 403, 409 or 503 refusal may all carry the mark. + */ +function markedApplicationRefusalError(cause: unknown, userMessage: string): Error { + const { status, code } = resolveThrownHttpError(cause, MARKED_REFUSAL_UNDECLARED_STATUS); + const err = new Error(userMessage) as Error & { + code?: string; + status?: number; + userMessage?: string; + cause?: unknown; + }; + err.code = code; + err.status = status; + err.userMessage = userMessage; + err.cause = cause; + return err; +} + +/** + * [#12536] The status a marked refusal takes when its producer named none — + * the REST sandbox door's own `declared ?? 400` (commit 8f266f1cd), reused rather than + * chosen again, so the metadata read door and the hook door classify one + * undeclared hook refusal identically. + */ +const MARKED_REFUSAL_UNDECLARED_STATUS = 400; + +/** + * [#12536] Carry a producer's `userMessage` mark onto an error this file is + * re-wrapping — the exact sibling of {@link carryCatalogedErrorCode}, one + * field over. + * + * A re-wrap that drops the mark destroys the channel commit 79c46da90 added just as completely + * as building a fresh error does, and for the same reason: `declaredUserMessage` + * reads the TOP level, never `cause`. Copying only the marked field quotes + * nothing else — the cause's diagnostic `message` stays governed by whichever + * withhold rule that exit already applies (#8136), unchanged. + */ +function carryDeclaredUserMessage(target: Error, source: unknown): void { + const marked = declaredUserMessage(source); + if (marked !== undefined) (target as Error & { userMessage?: string }).userMessage = marked; +} + +/** + * [#5532] The terminal "this metadata item does not exist" — structured, so it + * stops falling out of `mapDataError`'s catch-all. + * + * A miss is a 404 with the catalog's own not-found code + * (`HttpStatusErrorCodeMap[404] === 'RESOURCE_NOT_FOUND'`, and the spelling + * `GET /meta/:type/:name` already emits from its app-visibility gate). Before + * this, the throw was a bare `Error`: no `status`, no `code`, so it reached the + * REST boundary's terminal branch and was answered — verbatim as a 400 before + * #5489, as a sanitised `500 INTERNAL_ERROR` after it. Both are wrong answers + * for a plain miss, in opposite directions; neither could be told apart from a + * genuine fault by any client. + */ +function metadataItemNotFoundError(type: string, name: string): Error { + const err = new Error(`Metadata item ${type}/${name} not found`) as Error & { + code?: string; + status?: number; + }; + err.code = 'RESOURCE_NOT_FOUND'; + err.status = 404; + return err; +} + +/** What one pass of the `batchData` record loop produced (ADR-0119 D4). */ +export type BatchDataLoopOutcome = { results: BatchDataRowResult[]; succeeded: number; failed: number }; + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { + carryCatalogedErrorCode, + carryDeclaredUserMessage, + metadataIssueHeadline, + metadataItemNotFoundError, + overlayDeleteFailureMessage, +}; diff --git a/packages/metadata-protocol/src/protocol-data-request-shapes.ts b/packages/metadata-protocol/src/protocol-data-request-shapes.ts new file mode 100644 index 00000000000..08a1fd6f013 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-data-request-shapes.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the by-id +// update record and the malformed filter-array refusal. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + VALID_AST_OPERATORS, +} from '@objectstack/spec/data'; + +/** + * The RECORD limb of `IDataEngine.update`'s declared result, for the four + * ingresses in this file whose call is BY-ID by construction (#16231). + * + * `update()` used to declare `Promise` and now declares the union its two + * dispatch paths actually answer: a record (or `null`) from the by-id exit + * (`driver.update`), or the affected-row COUNT from the predicate exit + * (`driver.updateMany`, #4639). Every call site below hands the engine a + * `where` naming exactly one primary key — or folds the row's id into the + * payload — with no `multi`, which `resolveEngineUpdateDispatch` resolves + * `by-id`. So the count limb is unreachable from here, and it is REFUSED + * loudly rather than cast away: a dispatch change that started routing these + * calls through `updateMany` would otherwise drop an affected count into a + * per-row receipt's `record` / `data` slot, where every consumer reads it as a + * row. + * + * The `null` limb is NOT refused and NOT narrowed away — it is passed through + * exactly as it was while the declaration said `any`. `update()`'s by-id exit + * answers `null` when the post-write readback leaves the caller's row scope + * (see `updateData`'s own note on that), while `DataProtocol`'s row receipts + * declare `record` / `data` non-null. That disagreement pre-dates this change, + * and this file preserves it rather than widening a shipped response shape as + * a rider; the assertion below is the one place it is written down. + */ +export function byIdUpdateRecord(result: Record | number | null): Record { + if (typeof result === 'number') { + throw new Error( + `A by-id update resolved an affected-row count (${result}) instead of a record. ` + + `This ingress addresses exactly one row, so the engine's predicate dispatch ` + + `(driver.updateMany) is not reachable from it — the dispatch ladder or this ` + + `call site changed.`, + ); + } + return result as Record; +} + +/** + * A 400 for a `$filter` ARRAY that looks like a filter AST but is not one. + * + * The message has to be *actionable from the request*, which is the whole point + * of rejecting here rather than letting a driver fail later: the caller sent a + * query parameter, so the error names the offending element and the vocabulary + * it was checked against — not a driver-internal builder state. + * + * Diagnoses the three shapes `isFilterAST` refuses, in the order they occur in + * practice. #4121. Sibling of {@link unusableFilterError}, which covers the + * non-array ways a filter fails to become one (#4181); both emit + * `INVALID_FILTER` so the condition has one wire code however it was reached. + */ +function malformedFilterArrayError(filter: unknown[]): Error { + const detail = describeMalformedFilter(filter); + const err: any = new Error( + `Malformed $filter: ${detail} A filter array is a comparison ` + + `[field, operator, value], a logical node ["and"|"or", ...conditions], or a ` + + `list of those. Recognised operators: ${[...VALID_AST_OPERATORS].sort().join(', ')}.`, + ); + err.status = 400; + err.code = 'INVALID_FILTER'; + return err; +} + +/** The specific reason a filter array failed `isFilterAST`, for the message above. */ +function describeMalformedFilter(filter: unknown[]): string { + const [first, second] = filter; + const isKeyword = typeof first === 'string' && ['and', 'or'].includes(first.toLowerCase()); + + // `["and"]` / `["or"]` with nothing to join. The one shape that still + // returned every row silently after #3948: the driver sets its join mode, + // matches no element, and emits no predicate. + if (isKeyword && filter.length < 2) { + return `logical node ["${String(first)}"] has no conditions to join.`; + } + // A bare triple whose operator is outside the AST vocabulary — the original + // `before` / `after` / `'not in'` case. + if (typeof first === 'string' && !isKeyword && typeof second === 'string' + && !VALID_AST_OPERATORS.has(second.toLowerCase())) { + return `unrecognised operator "${second}" in [${JSON.stringify(first)}, ...].`; + } + // An element that is neither a join keyword nor a nested condition. + const badIndex = filter.findIndex( + (item) => !Array.isArray(item) + && !(typeof item === 'string' && ['and', 'or'].includes(item.toLowerCase())), + ); + if (badIndex >= 0 && filter.some((item) => Array.isArray(item))) { + const bad = filter[badIndex]; + return `element ${badIndex} is ${bad === null ? 'null' : typeof bad}, ` + + `expected a condition array or a logical keyword.`; + } + return `${JSON.stringify(filter)} is not a recognised filter shape.`; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { malformedFilterArrayError }; diff --git a/packages/metadata-protocol/src/protocol-discovery-services.ts b/packages/metadata-protocol/src/protocol-discovery-services.ts new file mode 100644 index 00000000000..90db05925f9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-discovery-services.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the discovery +// service configuration (route and route-less slots). +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { inProcessServiceMessage } from '@objectstack/spec/system'; + +/** + * Service Configuration for Discovery + * Maps service names to their routes and plugin providers. + * + * A missing `route` means the service has NO HTTP surface — discovery must + * not advertise a route for it (ADR-0076 D12, #2462: an advertised route + * with no mounted handler 404s and misleads consumers). Such entries carry + * `noHttpSurface` instead, stating how to report an occupant that does not + * self-describe: `realtime`'s advertised capability IS the missing HTTP/WS + * surface, so an in-process bus is `degraded`; `cache`/`queue`/`job` are + * kernel-internal contracts fully served in-process (#4318), so an unmarked + * real implementation stays `available`. Either way `handlerReady` is + * reported `false` — for a route-less slot it is not a proxy for anything, + * it is the fact itself. + */ +/** + * [#4093 follow-up] `plugin` is no longer written here. It named the package a + * consumer should install, and ten of the fifteen names did not exist — + * `plugin-redis` / `plugin-bullmq` / `job-scheduler` / `plugin-notifications` / + * `plugin-storage` / `plugin-automation` / `ui-plugin`, plus `plugin-ai`, + * `plugin-search` and `plugin-workflow` for slots nothing implements at all. + * The value is surfaced as discovery's `provider` and as its remedy line, so a + * wrong name is a dead end handed to whoever is trying to fix their stack. + * + * It now comes from `CORE_SERVICE_PROVIDER` in `@objectstack/spec/system`, the + * one table both discovery builders read, verified against what actually + * registers each slot and guarded by `scripts/check-service-providers.mjs`. + * Only the ROUTE stays local — that is this builder's own knowledge. + */ +const SERVICE_CONFIG: Record = { + // Plugin-provided like every other optional service since the degraded + // ObjectQL fallback was retired (#3891): advertised iff the real engine + // is registered — never hardcoded 'available' (the pre-#2462 lie the + // fallback existed to paper over). + analytics: { route: '/api/v1/analytics' }, + auth: { route: '/api/v1/auth' }, + automation: { route: '/api/v1/automation' }, + // Kernel-internal slots (#4318): their providers (service-cache/-queue/ + // -job) mount no HTTP routes — these are in-process contracts, not HTTP + // capabilities, so there is no route to advertise and never will be. The + // /api/v1/cache|queue|jobs paths this table used to declare existed + // nowhere else in the repository; every default boot advertised them next + // to the fallbacks' own `handlerReady: false` — a single ServiceInfo + // contradicting itself. + cache: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('cache') } }, + queue: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('queue') } }, + job: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('job') } }, + ui: { route: '/api/v1/ui' }, + // `workflow: { route: '/api/v1/workflow' }` retired with the slot (#4451, + // v17): nothing ever registered or resolved it (ADR-0115 Evidence 5) and + // no host ever mounted the path. State machines are `state_machine` + // validation rules; approvals are flow nodes (ADR-0019). + // service-realtime is an in-process pub/sub bus; nothing mounts + // /api/v1/realtime, so no route is advertised (D12, #2462). Unlike the + // kernel-internal slots above, the capability this slot advertises is + // realtime push to clients — without a surface that IS reduced, so an + // unmarked bus reports degraded. Message matches the dispatcher builder. + // + // [#14646] And it is the one CHANNEL SLOT (`CHANNEL_SURFACE_SLOTS`, + // `@objectstack/spec/api` — the one place that membership is declared, read + // by BOTH discovery producers, which is why it is not a field of this + // table). The row used to yield `enabled: true` ("the slot is filled") + // beside the message below ("no surface is mounted") — both true at once, + // which is how `enabled` came to carry two meanings and a console client + // came to subscribe to nothing. `enabled` is now `isSubscribableChannel` + // and the route is the one the occupant names, so the stock in-process bus + // advertises no channel while a host that really mounts one still does + // (maintainer ruling A, 2026-09-04 — realtime stays out of open core). + // `noHttpSurface` stays: it supplies the status and the sentence for the + // unmounted case, which is every host the open framework ships. + realtime: { noHttpSurface: { statusWhenUnmarked: 'degraded', message: 'In-process event bus only — no HTTP/WS realtime surface is mounted' } }, + notification: { route: '/api/v1/notifications' }, + ai: { route: '/api/v1/ai' }, + i18n: { route: '/api/v1/i18n' }, + // `graphql: { route: '/graphql' }` was here until #4451. It was never a + // `CoreServiceName`, so nothing could ever occupy the slot and the entry + // was unreachable — but it declared a path the dispatcher had already + // removed as out of the product plan (`http-dispatcher.ts`: "/graphql + // removed — GraphQL is not in the product plan", #2462 follow-on). A + // route nobody serves, for a slot that does not exist, in the table SDKs + // and AI clients read. + // Canonical slot since #9683 (was `file-storage`; that spelling stays a + // deprecated v17 registry alias, mirrored verbatim into the services map + // after the loop below — never a SERVICE_CONFIG row of its own). + storage: { route: '/api/v1/storage' }, + search: { route: '/api/v1/search' }, +}; + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { SERVICE_CONFIG }; diff --git a/packages/metadata-protocol/src/protocol-door-requests.ts b/packages/metadata-protocol/src/protocol-door-requests.ts new file mode 100644 index 00000000000..4e38539800b --- /dev/null +++ b/packages/metadata-protocol/src/protocol-door-requests.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the request +// shapes of the four single-item doors (save, publish, rollback, delete). +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { type MetadataAuthoringClient, type MetadataAuthoringSurface } from '@objectstack/spec/api'; +import type { MetadataWriteFace } from './protocol-write-face.js'; + +/* + * [#28355] The request shapes of the four single-item doors. Each door is a + * public wrapper (refusal audit) over a private body, and both read the shape + * from here: a private member's type is not published, so a wrapper typed off + * its body would publish an untyped `request`. Deliberately not exported — the + * published signatures are what they were, under these local names. + */ + +/** The request of {@link ObjectStackProtocolImplementation.saveMetaItem}. */ +type SaveMetaItemRequest = { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient }; + +/** The request of {@link ObjectStackProtocolImplementation.publishMetaItem}. */ +type PublishMetaItemRequest = { + type: string; + name: string; + actor?: string; + message?: string; + /** + * [commit 490879ad0] ADR-0048 — the software package the draft being promoted was + * listed under, when the caller has one to state. Forwarded whole to + * {@link promoteDraftForPublish}, which threads it into BOTH the #9612 + * gate closure and `repo.promoteDraft`, so the gate and the write + * resolve the draft under the SAME key it was listed by. + * + * Declared because it is REAL on this door, not merely tolerated: + * since commit 9e04c3e35 `POST /meta/:type/:name/publish?package=PKG_ID` states it + * on every HTTP-driven promotion that names a package — which is + * Studio's designer save-then-publish loop. Until it was declared the + * value flowed correctly but was invisible to every typed caller, and + * the only caller that states one reaches this method through a cast, + * so the binding was enforced by nothing. Commit 490879ad0 added the pins in + * `protocol-publish-drafts-package-scope.test.ts`. + * + * ⚠️ `null` is NOT the same as absent, and the difference is load + * bearing the whole way down: {@link promoteDraftForPublish} branches on + * the KEY BEING PRESENT (`'packageId' in request`), so an ABSENT key + * names no package while `null` pins the lookup to the UNBOUND row. + * [#23495] Naming no package is no longer "match any package": the + * publish promotes the draft a draft save naming no package writes + * ({@link packagelessDraftAddress}), and is refused `409 + * RESOURCE_CONFLICT` when the item's active rows are bound to two or + * more packages. Spread it in conditionally; never write + * `packageId: maybeUndefined`, which arrives as a present-and-undefined + * key, coerces to `null` downstream, and makes a package-bound draft + * unfindable — a silent `no_draft` on the untouched path. + */ + packageId?: string | null; + // [commit 490879ad0] `environmentId` is deliberately NOT declared here, although + // the REST door spreads it into this very request literal. It is the + // multi-kernel ROUTING key, and it is out of the protocol request shape + // by explicit maintainer ruling (recorded 2026-08-18, landed as commit 2a29caa53): + // `resolveProtocol(environmentId)` has already selected the target + // kernel before this method is entered, and this class reads its + // environment off the INSTANCE (`this.environmentId`, set at + // construction) and never off a request — measured, `request.environmentId` + // occurs nowhere in this file. `packages/rest` declares that one + // transport-level member on top of the declared shape instead + // (`TransportScopedMetaRequest`), which is where a routing key belongs. + // Adding it here would reverse that ruling rather than record it. + /** + * INTERNAL — `publishPackageDrafts` publishes many drafts and batch-applies + * every seed body in ONE loader pass afterwards (cross-seed references need + * multi-pass over the whole set), so it suppresses the per-item apply here. + */ + _skipSeedApply?: boolean; + /** [#22877] The bundle member whose draft to publish — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; +}; + +/** The request of {@link ObjectStackProtocolImplementation.rollbackMetaItem}. */ +type RollbackMetaItemRequest = { + type: string; + name: string; + toVersion: number; + actor?: string; + message?: string; + /** + * [#22877] The bundle member to revert — {@link resolveBundleMemberSelector}; + * omitted, the canonical one. The bundle shares one lineage counter, so + * a version of ANOTHER member answers 404 `VERSION_NOT_FOUND`. + */ + member?: string; +}; + +/** The request of {@link ObjectStackProtocolImplementation.deleteMetaItem}. */ +type DeleteMetaItemRequest = { + type: string; + name: string; + parentVersion?: string | null; + actor?: string; + state?: 'active' | 'draft'; + /** + * When true, also drop the object's physical table after the metadata + * is removed (object + active only; never `sys_`). Default false keeps + * delete non-destructive to data. Used by the "discard a previewed + * object" flow so a publish-to-preview leaves no orphan table. + * [#23415] Only with the object's last stored row: while another row + * of it stays stored, the delete is refused `409 RESOURCE_CONFLICT` + * ({@link dropStorageRemainingRowsRefusal}). + */ + dropStorage?: boolean; + /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; + /** + * [#23165, ADR-0048] The package whose row this delete removes, as a + * save names the row it writes (`?package=` on the REST door). Named, + * the delete removes that package's row and no other. Absent or `null` + * names no package: the delete removes the row of the one package the + * item's rows at its state are bound to, is refused `409 + * RESOURCE_CONFLICT` when they are bound to two or more, and with none + * removes the package-unbound row ({@link packagelessAddress}). + * [#23495] A draft discard naming none removes the draft a draft save + * naming none writes ({@link packagelessDraftAddress}), the one the + * per-item publish naming none promotes. + */ + packageId?: string | null; +}; + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export type { DeleteMetaItemRequest, PublishMetaItemRequest, RollbackMetaItemRequest, SaveMetaItemRequest }; diff --git a/packages/metadata-protocol/src/protocol-list-query-params.ts b/packages/metadata-protocol/src/protocol-list-query-params.ts new file mode 100644 index 00000000000..dae080b8e9e --- /dev/null +++ b/packages/metadata-protocol/src/protocol-list-query-params.ts @@ -0,0 +1,948 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the list- +// query parameter vocabulary: reserved names, near misses, arity, paging, projection, sort, +// filter reads and the stored-metadata search narrowing. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + parseFilterAST, + isFilterAST, + resolveSearchFieldResolution, + type SearchFieldMeta, + QUERY_TRANSPORT_ALIAS_SLOTS, + QUERY_TRANSPORT_DOLLAR_ALIASES, + QueryPageWindowParamSchema, + QueryTransportParamsSchema, + type QueryAliasConflict, + type QueryAST, +} from '@objectstack/spec/data'; +import { + isStoredMetadataBodyObject, + storedMetadataSearchRefusal, +} from './metadata-redaction.js'; + +/** + * The canonical `QueryAST` surface (`spec/data/query.zod.ts`), enumerated. + * + * Typed as `Record` so `tsc` pins it to the spec in BOTH + * directions: a key added there is a missing-property error here, a key removed + * there is an excess-property error here. That matters because the set below + * decides what is a query parameter and what is a field filter — silently + * drifting from the AST would resurrect exactly the #4134 failure for whatever + * key was added. + * + * The #4286 tombstones (`joins`, `windowFunctions`) still count: `retiredKey()` + * keeps a retired key in `keyof QueryAST`, so both stay listed — and therefore + * deliberately stay RESERVED at this boundary while the tombstone lives. That + * is the right compat posture: a caller still sending one belongs with the + * prescription, not with a silent `where.joins` filter. When a tombstone ages + * out (~two majors) and the key leaves the spec, the excess-property error + * here is the reminder that deleting its line UN-reserves the name — an object + * field genuinely called `joins` would start resolving as an implicit filter, + * which is a behavior change to call out in that changeset. + */ +const QUERY_AST_KEYS: Readonly> = { + object: true, fields: true, where: true, search: true, searchFields: true, + orderBy: true, limit: true, offset: true, top: true, cursor: true, + joins: true, aggregations: true, groupBy: true, having: true, + windowFunctions: true, distinct: true, expand: true, +}; + +/** + * [#4134] Every query-parameter name `findData` consumes itself, consulted + * AFTER the alias normalization in `findData` has run — so the wire spellings + * that get rewritten (`$top`→`top`→`limit`, `select`→`fields`, `sort`→ + * `orderBy`, `filter`/`filters`/`$filter`→`where`, `populate`/`$expand`→ + * `expand`, `skip`→`offset`, …) are already gone by this point and + * deliberately do NOT appear here. Anything still standing is either a name in + * this set or a candidate field filter. + * + * The structural AST keys (`object`, `joins`, `having`, `windowFunctions`) + * matter even though no querystring carries them: `POST /data/:object/query` + * hands its body in as `query`, and that body IS a `Partial`. Without + * them, `client.data.query('task', { object: 'task', limit: 5 })` would have + * its `object` key read as a filter and match zero rows. + * + * A name in this set can never be used as an implicit field filter, so an + * object with a field genuinely called e.g. `count` or `cursor` must filter it + * through the explicit form (`?filter={"count":3}`). That trade-off predates + * #4134 for the original members; it is called out here so the next person to + * add one knows what they are spending. + */ +const RESERVED_LIST_QUERY_PARAMS: ReadonlySet = new Set([ + ...Object.keys(QUERY_AST_KEYS), + // Transport-only extras the normalizer consumes but the AST does not name. + 'count', // ?count / $count — response flag, not a projection + // `searchFields` used to be listed here as such an extra. It is a named + // AST key since #3899 declared it (ADR-0061 P1), so it now arrives through + // the spread above and the type-level pin covers it — the hand-maintained + // copy would have been a second source that could silently fall out of step. + // Server-derived, never caller input (stripped then re-set from `request`). + 'context', +]); + +/** + * [#4134] High-frequency wrong guesses → the parameter that actually works. + * Keys are normalized (lower-cased, `_`/`-` stripped) so `pageSize`, + * `page_size` and `PAGE-SIZE` all land on the same entry. + * + * This is a HINT table, not an alias table: nothing here is accepted as input. + * Adding an entry makes a rejection more helpful; it never makes a request + * succeed, so it does not create the second de-facto contract Prime Directive + * #12 warns about. + */ +export const QUERY_PARAM_NEAR_MISS: Readonly> = { + // page-size dialects (the #4134 repro: `?pageSize=5` → 200 + empty list) + pagesize: 'top', persize: 'top', perpage: 'top', pagelimit: 'top', + rowsperpage: 'top', pagecount: 'top', size: 'top', take: 'top', + first: 'top', max: 'top', maxresults: 'top', maxrecords: 'top', + // page-offset dialects + page: 'skip', pageno: 'skip', pagenum: 'skip', pagenumber: 'skip', + pageindex: 'skip', start: 'skip', startindex: 'skip', startat: 'skip', + // sorting + sortby: 'sort', sortfield: 'sort', sortorder: 'sort', order: 'sort', + ordering: 'sort', + // search + q: 'search', keyword: 'search', keywords: 'search', term: 'search', + searchterm: 'search', querytext: 'search', + // filtering + filterby: 'filter', criteria: 'filter', conditions: 'filter', + // projection / relations + columns: 'select', include: 'expand', includes: 'expand', with: 'expand', +}; + +/** + * The OData spelling of each parameter {@link QUERY_PARAM_NEAR_MISS} points at. + * NOT derivable by prefixing `$` — `sort` is `$orderby`, and suggesting a + * `$sort` that the unsupported-`$` guard rejects would just hand the caller a + * second 400. + */ +export const ODATA_SPELLING: Readonly> = { + top: '$top', skip: '$skip', sort: '$orderby', search: '$search', + filter: '$filter', select: '$select', expand: '$expand', +}; + +// [#16066] The transport alias tables this normalizer folds by are DECLARED, +// and they are declared in the spec — `QUERY_TRANSPORT_ALIAS_SLOTS` and +// `QUERY_TRANSPORT_DOLLAR_ALIASES` (`@objectstack/spec/data`), imported above. +// +// They used to live here as module-private `WIRE_QUERY_ALIAS_SLOTS` / +// `WIRE_DOLLAR_ALIASES`: the spec's own table extended, in this file, with +// spellings no schema named. That extension is what made the `findData` door +// accept a second vocabulary through a slot declaring only the first — two +// dialects, one slot, one of them declared. Folding by the spec's export is +// the single source that closes it; ⛔ do not re-extend a table here. + +/** + * [#7321] The list-query slots whose DECLARED value type admits an array, by + * canonical key. Everything else this normalizer reads — including a leftover + * key lowered into an implicit field filter — is single-valued, and an array on + * it is a repeated wire parameter (see {@link assertQueryParamArity}). + * + * This set is the whole judgement. `IHttpRequest.query` is + * `Record< string, string | string[] >` and the array arm is produced by a real + * first-party adapter (`NodeHttpServer` hands `?x=1&x=2` through as + * `['1','2']`, measured over a socket on #6878), so `Array.isArray` at this + * boundary means one of exactly two things: a repeated querystring parameter, + * or a JSON array in a `POST /data/:object/query` body. This normalizer serves + * BOTH ingresses and cannot tell them apart — which is why the rule keys off the + * declared TYPE rather than off the request. On a slot that never declares an + * array, `Array.isArray` is unambiguous evidence of repetition; on a slot that + * does, it is the ordinary shape and must not be touched. + * + * Per member, why the array is legal (`packages/spec/src/data/query.zod.ts`): + * - `fields` — `z.array(FieldNodeSchema)`; `?$select=a&$select=b` IS the + * projection `['a','b']`. + * - `orderBy` — `z.array(SortNodeSchema)`, and `normalizeSortNodes` has an + * explicit `string[]` arm: repetition COMPOSES into a + * multi-key sort (`?sort=name&sort=-age`), it does not + * conflict. + * - `expand` — the name-array arm lowers to `{name: {object: name}}`. + * - `searchFields` — `z.array(z.string())`; the engine reads the comma-string + * and the array from either slot. + * - `where` — a FILTER AST is an array (`['status','=','open']`), so a + * blanket arity refusal here would reject the AST body form + * outright. Arity for this slot is judged one layer up, at + * the REST querystring ingress (`assertFilterParamSuppliedOnce`, + * #7390) — the only layer that knows an array on this wire + * is a repetition and can be nothing else. A repeated + * `?filter=` never reaches this block on `GET /data/:object`; + * worse, `?filter=status&filter=%3D&filter=open` spells a + * valid AST and succeeds with a filter nobody expressed. + * - `groupBy` — `z.array(GroupByNodeSchema)`. + * - `aggregations` — `z.array(AggregationNodeSchema)`. + * - `joins` / `windowFunctions` — retired ARRAY keys (#4286). The tombstone, + * not an arity refusal, must stay their answer. + * + * Deliberately NOT here, each because the spec declares a scalar: `limit`/`top` + * and `offset` (`z.number()`), `search` (`string | FullTextSearch`), `object` + * (`z.string()`), `having` (a `FilterCondition` OBJECT — the engine has no AST + * arm for it), the `count` response flag, and the retired scalars `cursor` / + * `distinct`. + */ +const ARRAY_VALUED_QUERY_SLOTS: readonly string[] = [ + 'fields', 'orderBy', 'expand', 'searchFields', 'where', + 'groupBy', 'aggregations', 'joins', 'windowFunctions', +]; + +/** + * [#7321] {@link ARRAY_VALUED_QUERY_SLOTS} expanded to every TRANSPORT spelling that + * reaches it, derived from the same two tables the fold uses so a new alias + * cannot silently lose its array arm — the failure mode would be `?$select=a& + * $select=b` starting to 400, i.e. the damage case this card exists to avoid. + */ +const ARRAY_VALUED_LIST_QUERY_PARAMS: ReadonlySet = (() => { + const names = new Set(ARRAY_VALUED_QUERY_SLOTS); + for (const slot of QUERY_TRANSPORT_ALIAS_SLOTS) { + if (!names.has(slot.canonical)) continue; + for (const alias of slot.aliases) names.add(alias); + } + for (const [dollar, bare] of QUERY_TRANSPORT_DOLLAR_ALIASES) { + if (names.has(bare)) names.add(dollar); + } + return names; +})(); + +/** + * [#7321] A parameter this normalizer reads as single-valued, supplied more + * than once. + * + * ## Why a refusal, and why this code + * + * `?$top=1&$top=2` is a well-formed request carrying two irreconcilable + * intents. Picking one is the silent drop itself, and coercing the pair is + * worse: `Number(['1','2'])` is `NaN`, so the window reached the driver as + * `limit: NaN` — driver-dependent behaviour under a 200, never an error. That + * is the same class #6928 / PR #7299 refused one layer over on + * `GET /api/v1/notifications`, and the same rule commit 293476148 / #6877 landed in + * `packages/rest` (`readSingleQueryValue` / `repeatedQueryParamMessage`). + * + * ## The wording below is MODELLED ON theirs — it is not a verbatim copy + * + * A caller who repeats a parameter on two different routes is told the same + * RULE, in two texts that differ in two measured ways (#17813): + * + * 1. The parameter name is quoted differently — single quotes here + * (`The 'top' query parameter …`), double quotes in `packages/rest` + * (`The "top" query parameter …`) — so the two strings diverge at the + * FIRST quoted character, shared prefix included. + * 2. This one appends the `It was NOT applied as a list: …` clause naming + * the `Number(['1','2'])` coercion above. Additive, and absent there. + * + * ⛔ Neither delta is a stray to "repair" in passing: both texts are the + * published output of released endpoints, so aligning the two is a change to + * what two doors answer, not a comment fix. + * + * ## Why the text is copied at all, now that half the reason has gone + * + * #17672 published `repeatedQueryParamMessage` from `@objectstack/rest`'s + * entry, so the sentence that module owns became importable by a sibling + * package for the first time — but not from HERE. `@objectstack/rest` is in + * neither this package's `dependencies` nor its `devDependencies`, and the + * arrow points the other way: `@objectstack/rest` dev-depends on + * `@objectstack/metadata-protocol` (`query-multiplicity.ts`'s header records + * that as why it cannot derive the filter-slot spellings from here at + * runtime). Importing the message would invert a package dependency and + * settle where the sentence lives for THREE consumers — this normalizer, the + * rest handlers, and the dispatcher domain that already calls the message + * function — a layering question deliberately left open. Until it is + * answered, the copy is the only form reachable from this package, ⛔ nothing + * holds the two texts equal, and an edit to either one does NOT move the + * other. + * + * `INVALID_REQUEST` / 400 is what {@link conflictingQueryParamsError} in this + * same normalizer already answers for the IDENTICAL condition reached the other + * way — two SPELLINGS of one slot carrying different values (#4181 → #3795). + * One slot given two values is one defect; it must not carry two codes + * depending on whether the caller repeated `filter` or wrote `filter` and + * `where`. (The rest layer spells its 400 `VALIDATION_ERROR` and the runtime + * layer `VALIDATION_FAILED`; those are each package's house catalog member for + * a 400, registered per package in `error-code-ledger.zod.ts`. The RULE and the + * status are what have to agree across the three, and do.) + * + * ## Why the count and not the values + * + * Two identical values are still two occurrences and are still refused: "at + * most one DISTINCT value" would be a de-duplication rule no caller can + * predict, while "supply it at most once" is checkable client-side without + * knowing anything about our semantics (#6877). + */ +function repeatedQueryParamError(param: string, count: number): Error { + const err: any = new Error( + `The '${param}' query parameter was supplied ${count} times. Supply it at most once — ` + + 'this endpoint will not choose between conflicting values. It was NOT applied as a ' + + 'list: a single-valued parameter given an array coerces to a value nobody asked for ' + + `(Number(['1','2']) is NaN), which the driver then answers under a 200.`, + ); + err.status = 400; + err.code = 'INVALID_REQUEST'; + err.param = param; + return err; +} + +/** + * [#25229] The page window (`limit` / `offset`, spelled `$top` / `top` / + * `$skip` / `skip` on the wire) as the non-negative integer every driver + * needs, or a refusal naming the parameter the caller wrote (#4226). + * + * The bare `Number(...)` this replaces failed three ways, none of them loud: + * + * - `NaN` (`?$top=abc`) and a negative limit (`?$top=-1`) reached the driver, + * which drops a window it cannot read — every row answered under a 200, the + * #7321 class (a malformed window served as if no window was sent); + * - a negative offset (`?$skip=-1`, `{"offset": -1}`) reached knex's + * `QueryBuilder.offset`, which throws — a 500 for a caller's mistake; + * - a blank value (`?$top=`) is `Number('') === 0`, an empty page nobody + * asked for. + * + * A blank value is refused, not read as absent. The blank `?filter=` is + * absent because an empty filter and no filter mean the same thing; an empty + * window does not — absent means "every row", so reading `?$top=` that way is + * the silent widening above under another name, and reading it as `0` is the + * guess this replaces. `0` itself stays valid: `$top=0` asks for the total + * alone. + * + * Only the two arms a door can deliver are read: a number (a JSON body) and a + * string (a querystring). Anything else — `true`, which `Number` would quietly + * make `1` — is refused like any other value that is not a count. + * + * [#25269] The accept set is the spec's `QueryPageWindowParamSchema`, parsed + * here rather than restated: the same declaration is what `FindDataRequestSchema` + * and `HttpFindQueryParamsSchema` publish for these parameters, so a client + * generated from them is told exactly what this check lets through. + */ +function pageWindowValue(value: unknown, param: string): number { + const window = QueryPageWindowParamSchema.safeParse(value); + if (window.success) return window.data; + // `String` for a number: `JSON.stringify(Infinity)` is `null`. + const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value); + const err: any = new Error( + `Query parameter '${param}' must be a non-negative integer; got ` + + `${shown.length > 64 ? `${shown.slice(0, 64)}…` : shown}. The query was not run: a window ` + + 'the driver cannot read is either dropped, answering every row, or rejected by the database.', + ); + err.status = 400; + err.code = 'INVALID_REQUEST'; + err.param = param; + throw err; +} + +/** + * A refused query value as the refusal quotes it: JSON, so a blank or + * whitespace-only value is visible, and bounded, so a long one cannot bury + * the sentence around it. [#27484] Exported for the search-fields gate + * (`protocol-query-validation.ts`), which refuses an empty override in the + * projection's words. + */ +export function quotedQueryValue(value: unknown): string { + // `String` for a number: `JSON.stringify(Infinity)` is `null`. + const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value); + return shown.length > 64 ? `${shown.slice(0, 64)}…` : shown; +} + +/** + * [#26644] A projection (`fields`, spelled `$select` / `select` on the wire) as + * the list of field names the engine reads, or a refusal naming the parameter + * the caller wrote. + * + * A comma string is split and trimmed as it always was; what is refused is a + * value that names NO field — a blank or comma-only string, or an empty array. + * It used to become `[]`, which `assertProjectionFieldsExist` has nothing to + * check in and every driver serves as no projection: every column, under a + * 200. Absent means "every column"; an empty projection asks for none, so + * reading it as absent is the widening {@link pageWindowValue} refuses on the + * row axis (a blank `$top`), here on the column axis. + * + * One rule for both routes that read a projection: the list's `findData` and + * the by-id `getData`. + */ +function projectionFieldsValue(value: string | readonly unknown[], param: string): unknown[] { + const fields = typeof value === 'string' + ? value.split(',').map((s) => s.trim()).filter(Boolean) + : [...value]; + if (fields.length > 0) return fields; + const err: any = new Error( + `Query parameter '${param}' names no field; got ${quotedQueryValue(value)}. The query was not run: ` + + 'an empty projection was read as no projection, which answers every column. Name at least one ' + + 'field, or leave the parameter out to read every column.', + ); + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = param; + throw err; +} + +/** + * [#26644] The response's total-count flag (`count`, spelled `$count` on the + * wire) as the boolean `findData` reads its opt-out from, or a refusal naming + * the parameter the caller wrote. + * + * The accept set is the spec's `QueryTransportParamsSchema.count`, parsed here + * rather than restated: the boolean, or the two strings a querystring carries + * it as (`null` is absent). Any other value used to stay as it was, so the + * `options.count === false` opt-out read it as "count anyway" — `?$count=maybe` + * reported a `total` exactly like `?$count=true`. + */ +function countFlagValue(value: unknown, param: string): boolean | null | undefined { + const flag = QueryTransportParamsSchema.shape.count.safeParse(value); + if (flag.success) return typeof flag.data === 'string' ? flag.data === 'true' : flag.data; + const err: any = new Error( + `Query parameter '${param}' must be true or false; got ${quotedQueryValue(value)}. The query was not ` + + 'run: any other value was read as absent, which counts and reports a total.', + ); + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = param; + throw err; +} + +/** + * [#7321] Refuse a repeated occurrence of any list-query parameter this + * normalizer reads as single-valued, and normalise the benign one-element array + * away so nothing below has to know the union existed. + * + * Runs at the TOP of `findData`, ahead of the `$`-alias pass and the #3795 slot + * fold, for two reasons: + * + * 1. The message then quotes the parameter the caller actually wrote — the + * #4226 discipline. After the fold, `?$top=1&$top=2` would be reported as + * `'limit'`, a name absent from the request. + * 2. The fold compares slot spellings by `JSON.stringify`, so an unchecked + * `?top=1&top=2&limit=1` reaches it as `['1','2']` vs `'1'` and is refused + * as a "conflicting query parameters" problem — a true refusal with a false + * diagnosis. Checking arity first means the caller is told what is actually + * wrong. + * + * Length 0 is DELETED rather than set to `undefined` (which is what the rest + * layer's `refuseRepeatedQueryParams` does with it): the leftover-key bucket + * below reads `Object.keys(options)`, so a key left behind carrying `undefined` + * would be lowered into an implicit `{field: undefined}` predicate. "Not + * supplied" has to mean absent here, not present-and-empty. + */ +function assertQueryParamArity(options: Record): void { + for (const name of Object.keys(options)) { + const value = options[name]; + if (!Array.isArray(value)) continue; + if (ARRAY_VALUED_LIST_QUERY_PARAMS.has(name)) continue; + if (value.length > 1) throw repeatedQueryParamError(name, value.length); + // length 1 → one occurrence an adapter encoded as an array; length 0 → + // no occurrence at all. + if (value.length === 0) delete options[name]; + else options[name] = value[0]; + } +} + +/** + * [#4181 → #3795] Spellings of ONE slot carrying DIFFERENT values. Two values + * for one slot cannot be reconciled — merging them would invent an intent the + * caller never expressed, and picking one is the silent drop itself — so an + * ambiguous request is refused. Redundant identical spellings pass. #4181 + * established this on the filter slot; the fold now applies it to all five. + * + * `spellingFor` maps each folded name back to the wire spelling the caller + * actually wrote (`$orderby`, not `orderBy`) — the #4226 discipline. + */ +function conflictingQueryParamsError( + conflict: QueryAliasConflict, + spellingFor: (name: string) => string, +): Error { + const names = conflict.spellings.map((s) => `'${spellingFor(s)}'`).join(', '); + const err: any = new Error( + `Conflicting query parameters: ${names} are spellings of the same parameter ` + + `(canonical '${conflict.canonical}') and were given different values. Send exactly one.`, + ); + err.status = 400; + err.code = 'INVALID_REQUEST'; + return err; +} + +/** + * [#4181] A filter the normalizer cannot turn into a usable `FilterCondition` + * by any route other than the array shapes {@link malformedFilterArrayError} + * already diagnoses: unparseable JSON, or JSON that parses to something no + * driver can read (a number, a bare string, `null`). + * + * Carries `INVALID_FILTER` — the standard-catalog code (`errors.zod.ts`, + * "Invalid filter expression") that #4121 introduced on this same code path for + * the array case. One condition, one wire code, however the caller reached it: + * a `$filter` array with a bad operator and a `?filter=` that is not JSON are + * the same answer to the same question ("this filter cannot run"). + * + * The message states the filter was NOT APPLIED, because that is the part a + * caller cannot infer: the pre-#4181 behavior was an ordinary-looking 200 over + * the unfiltered set. + */ +function unusableFilterError(param: string, detail: string): Error { + const err: any = new Error( + `Query parameter '${param}' ${detail}. It was not applied, and an unapplied ` + + 'filter would have returned the unfiltered result set.', + ); + err.status = 400; + err.code = 'INVALID_FILTER'; + err.param = param; + return err; +} + +/** + * [#4226] A sort the normalizer cannot turn into a usable `SortNode[]`, or one + * that names a field the object does not have — or, since #4256, a dotted path + * (`account.company_name`) that would have to cross into a related record no + * driver joins for. + * + * Carries `INVALID_SORT` — the standard-catalog code (`errors.zod.ts`, + * "Invalid sort specification") that had sat in the catalog with no emitter + * since it was written. One condition, one wire code, however the caller + * reached it. + * + * The message spells out what an unapplied sort costs, because that is the part + * a caller cannot infer from the response: `sort` + `top` is how you ask for + * "the latest N", and a dropped sort turns that into an ARBITRARY N with a + * perfectly ordinary-looking 200 over it. The rows are all there and all real — + * which is exactly why nobody notices. + */ +export function invalidSortError( + param: string, + detail: string, + opts?: { hint?: string, extra?: Record }, +): Error { + const err: any = new Error( + `Query parameter '${param}' ${detail}. It was not applied, and an unapplied ` + + "sort returns the rows in an arbitrary order — which 'top'/'limit' then " + + 'slices into an arbitrary page.' + + (opts?.hint ?? ''), + ); + err.status = 400; + err.code = 'INVALID_SORT'; + err.param = param; + Object.assign(err, opts?.extra ?? {}); + return err; +} + +/** + * [#4721] A sort node that spells its direction `direction` instead of `order`. + * + * This is the one unknown key on this axis that has a KNOWN right answer, so it + * gets a rejection that carries the translation rather than a generic refusal. + * `direction` is not a typo — it is the live vocabulary of a neighbouring + * contract (better-auth's adapter `sortBy`), which + * `plugin-auth/objectql-adapter.ts` already translates to `order` by hand. + * A necessary translation nothing enforced is exactly ADR-0049's shape. + * + * Measured on `main` before this rejection existed, on the schema side of the + * same door: + * + * ``` + * SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' }) + * → { field: 'updated_at', order: 'asc' } + * ``` + * + * So the failure was not "unsorted" — it was sorted the OTHER WAY, and with a + * `limit` that means a different set of rows came back under a 200. Worse than + * the family `invalidSortError` was built for (#3948, #4226): those return the + * right rows in an arbitrary order, this one returns the wrong rows. + * + * `INVALID_SORT` rather than a new code: one condition — "this sort was not + * applied as written" — keeps one wire code however the caller reached it. + */ +function invalidSortDirectionKeyError(param: string, field: string): Error { + return invalidSortError( + param, + `spells the sort direction for '${field}' as \`direction\`, which is not a key on this ` + + 'axis — the QueryAST sort node is `{ field, order }`', + { + hint: + ` Write \`{ field: '${field}', order: 'desc' }\`. \`direction\` is` + + " the better-auth adapter's `sortBy` vocabulary, a genuinely different contract; on this" + + ' axis it was silently dropped and `order` fell back to `asc`, so a descending' + + ' request came back ascending — and with `limit`, a different set of rows.', + extra: { field, key: 'direction' }, + }, + ); +} + +/** + * [#4226] Every wire spelling of `sort`/`orderBy`, folded to the one shape the + * QueryAST declares (`SortNodeSchema[]`) — or a rejection. + * + * Four spellings arrive here today and only two of them ever reached a driver: + * + * | spelling | example | before | + * |:---|:---|:---| + * | string | `?sort=-created_at` | worked | + * | `SortNode[]` | `[{field,order}]` | worked | + * | `string[]` | `['-created_at']` | **dropped** — the client's own declared type (`orderBy?: string \| string[] \| SortNode[]`) | + * | `Record` | `{created_at:'desc'}` | **dropped** — what `GET /data/:object/export`, `GET /data/import/jobs` and objectui's calendar all emit | + * + * The two dropped ones never failed: `SqlDriver` guards its ORDER BY clause + * with `Array.isArray(query.orderBy)`, so a shape it could not read produced no + * clause at all. `GET /data/import/jobs` has been asking for `created_at desc` + * and serving insertion order ever since it was written; #4181 taught the + * export route to REJECT an unparseable `orderby`, but the parsed result then + * fell into this same hole one layer down. + * + * Normalizing here — in the one shared normalizer every ingress funnels through + * — is what gives `GET /data/:object`, `POST /data/:object/query`, the export + * route and the runtime dispatcher a single answer instead of four. Anything + * that still cannot be read as a sort is a 400 rather than a silent no-op: per + * #3948, an unapplied sort must not look like an applied one. + * + * [#4721] One key gets named treatment on top of that: a node written + * `{ field, direction }` is refused with {@link invalidSortDirectionKeyError} + * rather than read as "sort by `field`, direction unspecified". It is the wire + * half of a door whose schema half is `SortNodeSchema`'s + * `aliases: { direction: 'order' }` — both closed in one change, because + * guarding one door only is the asymmetry #1535 shipped and #4522 came back for. + */ +function normalizeSortNodes(value: unknown, param: string): Array<{ field: string, order: 'asc' | 'desc' }> { + const direction = (raw: unknown, subject: string): 'asc' | 'desc' => { + if (raw === undefined || raw === null || raw === '') return 'asc'; + const dir = String(raw).trim().toLowerCase(); + if (dir === 'asc' || dir === 'desc') return dir; + throw invalidSortError(param, `gives ${subject} the direction '${raw}', which is neither 'asc' nor 'desc'`); + }; + // `-field` / `field` / `field desc` — the querystring shorthand, also used + // for each element of the `string[]` form. + const fromShorthand = (raw: string): { field: string, order: 'asc' | 'desc' } | undefined => { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith('-')) { + const field = trimmed.slice(1).trim(); + return field ? { field, order: 'desc' } : undefined; + } + const [field, order] = trimmed.split(/\s+/); + return field ? { field, order: direction(order, `'${field}'`) } : undefined; + }; + const fromElement = (el: unknown, index: number): { field: string, order: 'asc' | 'desc' } | undefined => { + if (typeof el === 'string') return fromShorthand(el); + if (el && typeof el === 'object' && !Array.isArray(el)) { + const node = el as { field?: unknown, order?: unknown }; + if (typeof node.field === 'string' && node.field.trim()) { + // [#4721] `{ field, direction }` — the wire half of the door + // `SortNodeSchema`'s `aliases: { direction: 'order' }` closes on + // the schema side. Checked HERE and not left to the schema + // because an external caller's `orderBy` never reaches it: this + // normalizer runs at the ingress, ahead of any QueryAST parse. + // + // Rejecting only on a well-formed sort NODE is deliberate. In + // the sibling `{field: direction}` map form a key named + // `direction` is an ordinary column name ("sort by the + // `direction` column"), and that form does not reach this + // branch — it has no string `field`. + if ('direction' in node) throw invalidSortDirectionKeyError(param, node.field.trim()); + return { field: node.field.trim(), order: direction(node.order, `'${node.field}'`) }; + } + } + throw invalidSortError( + param, + `has an entry at position ${index} that names no field (received ${JSON.stringify(el) ?? typeof el})`, + ); + }; + + if (value === undefined || value === null || value === '') return []; + if (typeof value === 'string') { + return value.split(',').map(fromShorthand).filter((s): s is { field: string, order: 'asc' | 'desc' } => !!s); + } + if (Array.isArray(value)) { + return value.map(fromElement).filter((s): s is { field: string, order: 'asc' | 'desc' } => !!s); + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return []; + // A single `SortNode` sent unwrapped (`{field:'x',order:'desc'}`) is the + // one-element array it obviously means; anything else is read as the + // `{field: direction}` map, whose VALUES must be directions — that check + // is what stops a stray object being silently accepted as "no sort". + if (typeof (value as any).field === 'string') return [fromElement(value, 0)!]; + return entries.map(([field, dir]) => ({ field, order: direction(dir, `'${field}'`) })); + } + throw invalidSortError(param, `is a ${typeof value}, which names no field to sort by`); +} + +/** Fold a parameter name to its near-miss lookup key. */ +export function nearMissKey(name: string): string { + return name.toLowerCase().replace(/[_-]/g, ''); +} + +/** Levenshtein distance, bailed out early once it exceeds `max`. */ +function editDistance(a: string, b: string, max: number): number { + if (Math.abs(a.length - b.length) > max) return max + 1; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + let best = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost); + if (row[j] < best) best = row[j]; + } + if (best > max) return max + 1; + prev = row; + } + return prev[b.length]; +} + +/** + * [#4226] The field-typo half of {@link suggestQueryParam}, on its own. + * + * The `sort` / `select` / `expand` axes name a field DIRECTLY, so the + * parameter-dialect half above is not merely useless there but actively wrong: + * it would answer `?sort=order` with "did you mean the 'sort' query parameter", + * which is the parameter the caller already used. + */ +export function suggestFieldName(name: string, knownFields: readonly string[]): string { + const folded = nearMissKey(name); + // Only worth guessing for names long enough that a small distance is + // meaningful — at 3 chars everything is within 2 edits of everything. + if (folded.length >= 4) { + const max = folded.length <= 5 ? 1 : 2; + let best: string | undefined; + let bestDistance = max + 1; + for (const field of knownFields) { + const d = editDistance(folded, nearMissKey(field), max); + if (d < bestDistance) { bestDistance = d; best = field; } + } + if (best !== undefined && bestDistance <= max) { + return ` Did you mean the field '${best}'?`; + } + } + return ''; +} + +/** + * [#7534] The logical combinators a `FilterCondition` may carry. These hold + * NESTED CONDITIONS rather than naming a field, so {@link collectFilterFieldKeys} + * descends through them instead of judging them. + * + * Exactly the three the contract declares (`FilterConditionSchema`, + * `@objectstack/spec`) — `$and` / `$or` / `$not`. `$nor` is deliberately absent: + * it is a driver-INTERNAL lowering (`driver-memory` rewrites an input `$not` + * into a one-operand `$nor`, MongoDB's document-level negation) and is REFUSED + * as input vocabulary by that same driver, so a `$nor` arriving on the wire is + * not a combinator this layer should silently descend into. + */ +export const FILTER_LOGICAL_KEYS: ReadonlySet = new Set(['$and', '$or', '$not']); + +/** + * [#21544] The stored-metadata family's ONE filter-field collector: every column + * a read query's filters READ, judged the way the family's evaluate refusals + * ({@link storedMetadataBodyPredicateRefusal}, + * {@link storedMetadataHashEvaluateRefusal}) need it. The generic data door + * calls it, and so does the in-process reader-context seam in + * `@objectstack/runtime` (`stored-metadata-reader-seam.ts`), on the same query, + * so the two answer every filter identically. `[]` for an object outside the + * family ({@link isStoredMetadataBodyObject}) or a query that is not a record. + * + * The filter positions it reads are a read query's three: `where`, `filter` + * (the engine option alias a direct engine call may still carry) and each + * `aggregations[i].filter`. ⛔ Not `having`: its keys and references name the + * AGGREGATED row's columns — group keys and aggregation aliases — and the + * engine refuses any other name before a row exists, so a family column can + * never be read there, while an alias that happens to be spelled like one + * (`{ function: 'count', alias: 'metadata' }`) is a legitimate count. + * + * What counts as a read, and why it is more than the ingress gate's + * {@link collectFilterFieldKeys} collects. The first two rules are measured + * reaches at the generic data door before this collector existed (a family + * column evaluated, unrefused); the rest are the stricter collector's rules, + * adopted whole so the door and the seam agree — where measured, the door + * already refused those shapes elsewhere (its dotted-path rule, the engine's + * filter doors), and now the family's own refusal answers them first: + * + * - **A cross-field comparand is a read.** `{ name: { $eq: { $field: 'metadata' } } }` + * compares each row's `name` with its stored body, and the SQL drivers + * evaluate it: row presence discloses the referenced column exactly as a + * filter on it would. Every node under a field key that carries a string + * `$field` — an operator bag, a list member, an `addDays` offset — is a read + * of that column: the reference shape `driver-sql` resolves (the spec's + * `FieldReferenceSchema`), taken without its other refinements, so a + * malformed reference is refused here rather than read. + * - **Depth never hides a read.** The walk is iterative and visits every node + * once per reading, so it terminates on a self-referential live object + * without a depth backstop: the ingress collector's backstop stops at 32 + * levels, and a body predicate nested below it was evaluated unrefused. + * - **A dotted name reads its head.** `metadata.x` reads `metadata`, as a + * key and as a reference. + * - **A `$` key is never a column, and what sits beneath one is read as a + * condition** — the three declared combinators and any other alike, so an + * unrecognised combinator cannot hide a family column (the engine refuses + * one anyway; the refusal just arrives first). + * - **A field key's value is read for references only.** The keys of a + * nested-relation condition (`{ owner: { region: 'NA' } }`) are another + * object's columns; a `$field` beneath one is still collected, so the + * ambiguous spelling is refused rather than resolved. + * - **A `FilterArray` is lowered first** (`parseFilterAST`), the sugar a direct + * engine call still honours on `where`, so either authoring form reads the + * same. + * + * Each column is returned once. + */ +export function collectStoredMetadataFilterFields(object: string, query: unknown): string[] { + if (!isStoredMetadataBodyObject(object)) return []; + if (query === null || typeof query !== 'object' || Array.isArray(query)) return []; + const bag = query as Record; + const filters: unknown[] = [bag.where, bag.filter]; + if (Array.isArray(bag.aggregations)) { + for (const aggregation of bag.aggregations) { + if (aggregation !== null && typeof aggregation === 'object') { + filters.push((aggregation as { filter?: unknown }).filter); + } + } + } + const out = new Set(); + for (const filter of filters) collectFilterReads(isFilterAST(filter) ? parseFilterAST(filter) : filter, out); + return [...out]; +} + +/** The head segment of a column name: `metadata.x` reads `metadata`. */ +function headSegment(name: string): string { + return name.split('.')[0] as string; +} + +/** + * The walk {@link collectStoredMetadataFilterFields} runs over one filter + * condition. A node is visited as a CONDITION (its non-`$` keys are columns) + * or as a COMPARAND (the value under a column key, where only a `$field` + * reference is a read), at most once per reading — two readings, because a + * live object reached once as a comparand and once as a condition must be read + * both ways. + */ +export function collectFilterReads(root: unknown, out: Set): void { + const seenAsCondition = new WeakSet(); + const seenAsComparand = new WeakSet(); + const pending: Array<{ node: unknown; condition: boolean }> = [{ node: root, condition: true }]; + while (pending.length > 0) { + const { node, condition } = pending.pop() as { node: unknown; condition: boolean }; + if (node === null || typeof node !== 'object') continue; + const seen = condition ? seenAsCondition : seenAsComparand; + if (seen.has(node)) continue; + seen.add(node); + if (Array.isArray(node)) { + for (const member of node) pending.push({ node: member, condition }); + continue; + } + const record = node as Record; + if (!condition && typeof record.$field === 'string') out.add(headSegment(record.$field)); + for (const [key, value] of Object.entries(record)) { + if (condition && !key.startsWith('$')) { + out.add(headSegment(key)); + pending.push({ node: value, condition: false }); + } else { + pending.push({ node: value, condition }); + } + } + } +} + +/** + * The slice of an object definition {@link narrowStoredMetadataSearch} reads: + * the field map, the declared `searchableFields`, and the display field the + * search resolution falls back on. + */ +export interface StoredMetadataSearchSchema { + fields?: unknown; + searchableFields?: unknown; + nameField?: unknown; + displayNameField?: unknown; +} + +/** + * [#21207, #21544] A `search` on a stored-metadata table never scans its body or + * content-hash columns — the ONE narrowing the generic data door and the + * in-process reader-context seam (`@objectstack/runtime`) both call, so a + * search answers the same through either. + * + * A search is a substring filter the engine evaluates over every column it + * scans, and with no `searchableFields` declared it scans every text-like + * column — the stored body and both stored hashes among them. Over those it is + * the verifier the filter refusals close: a guessed hash, or a guessed prefix + * of withheld credential material, returns the row exactly when it is right. + * The authority on which columns a search may never scan is the family's + * search predicate ({@link storedMetadataSearchRefusal}), asked per field: + * + * - an EXPLICIT field list — `searchFields`, else the object-form + * `search.fields`, the engine's own precedence and both its shapes (a comma + * string or an array) — naming one is refused, `INVALID_FIELD` / 400, the + * evaluate refusals' envelope, under the caller's wire spelling of the slot + * (`wireSpelling`, the door's; the bare canonical name when absent); + * - [#28120] an explicit list that names NO field — an empty array, a blank or + * comma-only string, an array with no string entry — is not "names none": it + * is passed through untouched, for the engine's own refusal to answer + * (`400 VALIDATION_ERROR` for an empty one, #27732; `400 INVALID_FIELD` for + * one naming no searchable field). Narrowing it here wrote a non-empty set + * back over the caller's list, so an override the door and the engine both + * refuse was served. The door never reaches this with one: its ingress gate + * refuses it first; + * - a search that names none is NARROWED: the object's resolved searchable set + * ({@link resolveSearchFieldResolution}) minus those columns is returned, for + * the caller to run as `searchFields` — the engine intersects an override + * with that set and never widens it; + * - a set that narrows to nothing is refused rather than returned empty: an + * empty override is refused by the engine as one naming no field, a + * caller's mistake this caller did not make, so the family's own refusal + * names the cause instead. + * + * Returns `undefined` when the query runs as it is — an object outside the + * family, no search, an explicit list that passes or names no field, or no + * readable field map (the engine has none to expand a search over either; the + * same rule the door's field gates apply: a non-object, array or empty + * `fields`). Throws the refusal. + */ +export function narrowStoredMetadataSearch( + object: string, + query: Readonly>, + schema: StoredMetadataSearchSchema | null | undefined, + wireSpelling: Readonly<{ search?: string; searchFields?: string }> = {}, +): string[] | undefined { + if (!isStoredMetadataBodyObject(object)) return undefined; + const search = query.search; + const objectForm = search !== null && typeof search === 'object'; + const objectFormFields = objectForm ? (search as Record).fields : undefined; + const [explicit, param] = query.searchFields != null + ? [query.searchFields, wireSpelling.searchFields ?? 'searchFields'] + : objectFormFields != null + ? [objectFormFields, wireSpelling.search ?? 'search'] + : [undefined, '']; + const names: string[] = typeof explicit === 'string' + ? explicit.split(',').map((s) => s.trim()).filter(Boolean) + : Array.isArray(explicit) ? explicit.filter((f: unknown): f is string => typeof f === 'string') : []; + if (names.length > 0) { + const refusal = storedMetadataSearchRefusal(object, names, param); + if (refusal) throw refusal; + return undefined; + } + // [#28120] A list naming no field is the engine's to refuse, never narrowed. + if (typeof explicit === 'string' || Array.isArray(explicit)) return undefined; + if (search == null) return undefined; + const fields = schema?.fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) { + return undefined; + } + const { allowed } = resolveSearchFieldResolution({ + fields: fields as Record, + searchableFields: schema?.searchableFields as string[] | undefined, + displayField: (schema?.nameField ?? schema?.displayNameField) as string | undefined, + }); + const searchParam = wireSpelling.search ?? 'search'; + const narrowed = allowed.filter((field) => !storedMetadataSearchRefusal(object, [field], searchParam)); + if (narrowed.length === 0) { + const refusal = storedMetadataSearchRefusal(object, allowed, searchParam); + if (refusal) throw refusal; + return undefined; + } + return narrowed; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { + RESERVED_LIST_QUERY_PARAMS, + assertQueryParamArity, + conflictingQueryParamsError, + countFlagValue, + normalizeSortNodes, + pageWindowValue, + projectionFieldsValue, + unusableFilterError, +}; diff --git a/packages/metadata-protocol/src/protocol-meta-type-schema.ts b/packages/metadata-protocol/src/protocol-meta-type-schema.ts new file mode 100644 index 00000000000..73f33c45666 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-meta-type-schema.ts @@ -0,0 +1,620 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the canonical +// `/meta` type spelling, the served item and JSON Schema, and the overlay schema a save is +// judged against. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +// [#17502] The served JSON Schema publishes what an author MAY write, so a +// property no instance can satisfy — a `retiredKey()` tombstone, rendered +// `{ not: {} }` — is dropped from it. See the module header for the channels +// that keep carrying the retirement's prescription. +import { stripUnauthorableProperties } from './unauthorable-nodes.js'; +import { markErasedAuthoringInput } from './erased-authoring-mark.js'; +import { + applyAuditFieldGovernance, + // [#6562] The injection/strip pair over the shared injected-column + // definition table — see {@link governServedItem} / {@link stripServedSystemColumns}. + applyInjectedSystemColumns, + stripInjectedSystemColumns, +} from '@objectstack/metadata-core'; +import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, metaUrlSpellingRefusal } from '@objectstack/spec/shared'; +import { type FormView } from '@objectstack/spec/ui'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { z } from 'zod'; +import { + OBJECT_EXTENSION_ITEM_SCHEMA, + OBJECT_EXTENSION_TYPE, +} from './protocol-object-extension-item.js'; + +/** + * Canonical Zod schema per metadata type lives in + * `@objectstack/spec/kernel/metadata-type-schemas` and is exposed through + * {@link getMetadataTypeSchema}. Both save-time validation + * ({@link resolveOverlaySchema}) and the `/meta/types/:type` JSON Schema + * emitter consult that single source of truth, so adding a new + * metadata-type schema requires editing exactly one file (or calling + * `registerMetadataTypeSchema()` from a plugin). + */ +// (TYPE_TO_SCHEMA removed — use `getMetadataTypeSchema(type)` directly.) + +/** + * Canonical {@link FormView} layout per metadata type. Sourced from the + * shared {@link METADATA_FORM_REGISTRY} in `@objectstack/spec/system` so + * the runtime form payload, the i18n extractor, and Studio all read from + * a single source of truth. + * + * Types without an entry render with the auto-generated single-section + * layout derived from their JSON Schema (acceptable for simple types). + */ +const TYPE_TO_FORM: Readonly> = METADATA_FORM_REGISTRY; + +/** + * The ONE canonical spelling of a metadata type at the `/meta` read/write/delete + * boundary (#4432). + * + * Prime Directive #3 already fixes the answer — metadata type names are + * SINGULAR (`'action'`, `'view'`), REST paths are plural (`/meta/actions`) — and + * #3985 taught the per-type gates to accept both spellings. What it did not do + * is fold them, so the two spellings addressed two different namespaces and the + * layers below disagreed about which one an item lived in: + * + * - the `SysMetadataRepository` write/delete path already folded to singular, + * while the authorization tier above it (`isOverlayAllowed`, + * `isArtifactBacked`) and the registry heal below it + * (`restoreArtifactRegistryView`) read the caller's spelling; + * - `getMetaItems` registered overlay rows back into the SchemaRegistry under + * the caller's spelling. One plural-spelled read minted a plural registry + * entry, `listItems('actions')` stopped being empty, and the singular + * fallback that had been supplying the code-authored items never ran again — + * so one overlay row shadowed an entire code-authored listing, and survived + * the DELETE that was supposed to lift it. + * + * Folding at the boundary (rather than adding another spelling-tolerant lookup + * one layer down) is Prime Directive #12 applied to a type key: one contract, + * not N dialects. Reads of data AT REST still try the other spelling as a + * fallback — rows written under a plural `type` before this fix are real, and + * nothing rewrites them on upgrade. + * + * ## [#7894] It folds through the URL map, NOT the manifest map + * + * This used to read `PLURAL_TO_SINGULAR`, which is a MANIFEST-COLLECTION map + * (`objects: [...]`, `apps: [...]`) that `metadata-authoring-lint.ts` iterates + * to decide which stack-level collections exist. Four registry types are + * legitimately absent from it because they are not stack collections — + * `field`, `seed`, `external_catalog`, `translation`. At this boundary that + * absence did not read as "not a collection", it read as "unknown type", and an + * unknown type takes the PLUGIN path, which every authorization gate is + * permissive toward by construction. So `PUT /meta/fields/showcase_task.title` + * answered 200 and persisted a row under `type='fields'` — a second namespace + * for the same item, the #4432 defect exactly — while `PUT /meta/field/...` + * answered 403 NOT_OVERRIDABLE. The plural URL was a door around the singular + * URL's lock. + * + * {@link canonicalMetaUrlType} reads a map DERIVED from + * `DEFAULT_METADATA_TYPE_REGISTRY` (unioned with every manifest spelling, so + * nothing that resolved before resolves differently), which is why a newly + * declared type cannot arrive unmapped. Adding the four missing keys to the + * manifest map by hand would have fixed only today's four — and would have + * advertised a `fields: [...]` stack collection that does not exist. + * + * ⛔ Do not "fix" a future instance of this by teaching a predicate one layer + * down (`isNestedArtifactField` and friends) to accept a plural. That is the + * spelling-tolerant lookup this comment has rejected since #4432, and it would + * still persist the row under the plural `type`. + */ +export function canonicalMetaType(type: string): string { + return canonicalMetaUrlType(type); +} + +/** + * {@link canonicalMetaType} applied to a `{ type }` request, without mutating the + * caller's object — and the one checkpoint that REFUSES a spelling it cannot + * honour instead of forwarding it to the plugin path (#7894). + * + * Applied here rather than inside {@link canonicalMetaType} on purpose: this is + * the request boundary (all six `/meta` entry points funnel through it), while + * `canonicalMetaType` is also called on read EXITS by `governServedItem` and + * `stripServedSystemColumns`, where the type is already canonical and a throw + * would be a bug rather than a refusal. + * + * The refusal is deliberately narrow: {@link metaUrlSpellingRefusal} returns a + * verdict only for a spelling whose singular is a type the platform itself + * DECLARES, so a plugin-registered runtime kind can never trip it. See that + * function for why the rule is static rather than a live-registry lookup — + * and (#8424) for why this boundary consumes the composed VERDICT rather than + * the predicate parts: the spelling contract stays whole at its producer. + * + * ## [#8421] The SECOND verdict is not here + * + * This refusal is silent for a name that is not a plural of ANYTHING + * (`fieldz`), because such a name reaches for no declared type. That residue + * was closed by the maintainer's ruling of 2026-08-14 (「同意」), joint with + * #8586 — but not in this function, because the verdict that closes it cannot + * be answered from the request alone: it has to know whether the namespace it + * would create already exists. It lives on the mint door itself, as + * {@link ObjectStackProtocolImplementation.refuseUnmintableMetaType}, which + * carries the scoping argument and the measurements behind it. + */ +function canonicalizeMetaRequestType(request: T): T { + const refusal = metaUrlSpellingRefusal(request.type); + if (refusal) { + const err = new Error( + `'${request.type}' is not a recognised spelling of metadata type ` + + `'${refusal.declared}'. Address it as '${refusal.declared}' or '${refusal.hint}'. ` + + `Refused rather than treated as a plugin-registered type, because forwarding an unrecognised ` + + `spelling of a declared type would create a second namespace under type='${request.type}'.`, + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } + const type = canonicalMetaType(request.type); + return type === request.type ? request : { ...request, type }; +} + +/** + * The last thing every `/meta` read does to an OBJECT document before it leaves + * this service: make the field metadata it reports agree with what the engine + * enforces on the write path — in BOTH of the ways it used to disagree. + * + * The mismatch this closes is structural, not incidental. A `/meta` object read + * resolves through `sys_metadata` overlay → MetadataService → SchemaRegistry, + * and only the last of those three has been through `applySystemFields`, so the + * answer a caller got depended on which link produced it — with nothing in the + * response saying which one had. Two halves, filed and ruled separately: + * + * - **[#4513] the VALUE half.** The two stored layers answered with whatever + * the artifact/overlay body happened to declare, while `ObjectQL.update` was + * stripping caller writes to the audit family off the registry's + * post-injection schema. `created_at` read `readonly: false` and wrote as + * read-only, on the same field, at the same moment, from the one face a + * client can actually see (#4447 fixed the write half; this is the read + * half). {@link applyAuditFieldGovernance} normalizes a DECLARED audit field. + * - **[#6562] the PRESENCE half.** The stored layers reported the platform's + * own injected columns — `created_at`, `owner_id`, `organization_id`, + * `owning_business_unit_id`, … — as simply ABSENT, so an author reading an + * overlay-backed object reasonably concluded the columns do not exist, while + * every one of them is real in the database, filterable, orderable and + * enforced read-only on write. Maintainer ruling (2026-08-08), Option B: the + * read serves the EFFECTIVE runtime schema and the overlay-backed minority + * converges on the registry-backed majority. + * {@link applyInjectedSystemColumns} adds an UNDECLARED injected column. + * + * The two are composed rather than folded, because they do different things to + * different fields: governance rewrites what the author declared, injection only + * ever adds what nobody declared. Both return their input by reference when + * nothing was needed, so the registry-sourced path (injected AND governed at + * registration) and every non-object type pay a comparison and no copy. + * + * Applied per EXIT rather than inside `decorateMetadataItem`: decoration is a + * diagnostics concern whose output `stripReadDecorations` deliberately removes + * again on write, and neither of these is that — they are what the document + * means. The read exits are also the ONLY place injection may happen (ruling + * constraint 1): `getMetaItemLayered` calls this on `effective` and never on + * `overlay`, so Studio's "what you customised" diff keeps showing the row the + * author actually stored. + * + * ⛔ The write path owes this function a counterpart. See + * {@link stripServedSystemColumns} — without it the standard Studio GET → edit → + * PUT round-trip would persist the injected columns into `sys_metadata`, and the + * #4326 byte-identical invariant would break the day this shipped. + */ +export function governServedItem(type: string, item: T): T { + if (canonicalMetaType(type) !== 'object') return item; + return applyInjectedSystemColumns(applyAuditFieldGovernance(item)); +} + +/** + * [#6562] The write-path counterpart of {@link governServedItem}'s injection + * half: take the injected-but-undeclared system columns back off a body on its + * way IN, so a served document handed straight back still persists byte-identical. + * + * Exactly the shape, and exactly the reason, of the `stripReadDecorations` call + * beside it in `saveMetaItem` (#4326) — the write path persists the request body + * verbatim by design for every type but `view` (ADR-0005 appendix (c), the + * "Addendum — 2026-05-16 (c)"), so anything the READ adds must come + * off again on the way in or it is baked into `sys_metadata.metadata`, into its + * checksum, and into every history diff. Kept a SEPARATE strip from that one + * rather than folded into `METADATA_READ_DECORATIONS`, because the two lists are + * different in kind: a read decoration is derived diagnostics no schema accepts, + * whereas an injected column is a real, spec-valid field declaration an author + * may legitimately write — so this strip removes only a field byte-identical to + * the platform's own definition, and a declared `owner_id` carrying the author's + * own label survives untouched. + */ +export function stripServedSystemColumns(type: string, item: T): T { + return canonicalMetaType(type) === 'object' ? stripInjectedSystemColumns(item) : item; +} + +/** + * [#17501] Does a converted JSON Schema carry any authorable surface at all? + * + * `z.toJSONSchema()` answers a `ZodPipe` in its default `'output'` mode with a + * document that is syntactically a JSON Schema and semantically nothing: + * `{"$schema": "..."}` and not one property. It is truthy, so a `?? fallback` + * downstream never fires, and the endpoint serves a document that reads as + * "this type declares no constraints" when the truth is "this derivation could + * not see the type at all". + * + * A derivation is DEGENERATE when it names no property, offers no union arm, + * points at no `$ref` and opens no `additionalProperties` object — i.e. there + * is nothing an author or a form renderer could address. Every one of those + * four is a real authorable surface, so any one of them present means the + * derivation worked. + */ +function isDegenerateDerivation(json: Record | undefined): boolean { + if (!json || typeof json !== 'object') return true; + const properties = json.properties; + if (properties && typeof properties === 'object' && Object.keys(properties).length > 0) return false; + for (const arm of ['anyOf', 'oneOf', 'allOf'] as const) { + const value = json[arm]; + if (Array.isArray(value) && value.length > 0) return false; + } + if (typeof json.$ref === 'string') return false; + const additional = json.additionalProperties; + if (additional && typeof additional === 'object') return false; + return true; +} + +/** + * [#17501] One-shot per type: both derivations of this type's schema came back + * empty. Keyed by type name, not by schema reference, because the message names + * the type and repeating it per request is noise. + */ +const _warnedDegenerateDerivation = new Set(); + +/** + * Convert a Zod schema to a JSON Schema, returning `undefined` if no usable + * derivation exists — which is what lets the caller's `?? HAND_CRAFTED_SCHEMAS` + * arm fire. Cached per schema reference. + * + * ## [#17501] Why this tries TWICE, and why only sometimes + * + * `/meta/types` served `action` as `{"$schema": "..."}` — an empty schema for a + * type that accepts 48 keys — because `ActionSchema` is + * `lazySchema(() => actionObject().refine(...))`, a `ZodPipe`, and the OUTPUT + * derivation of a pipe carries no properties. The hand-crafted fallback + * declared for exactly this case never fired: conversion did not throw, it + * SUCCEEDED and returned a truthy husk. + * + * The obvious repair — derive everything with `io: 'input'`, the authoring + * shape a property panel actually wants, which `reference-sites.ts` already + * passes — was measured across the whole served surface and REFUSED. It is not + * a repair with a blast radius of one: 24 of the 26 types that carry a Zod + * schema answer differently under `input`, and the direction is a WEAKENING of + * a published contract — `required` entries 1132 to 867, `additionalProperties: + * false` 663 to 637. Serving the authoring shape for every type is a defensible + * contract change, but it is a `packages/spec` question with its own review, + * not a rider on this bug fix. (The card's own reading that thirteen types + * "answer identically" is a TOP-LEVEL PROPERTY COUNT, which cannot see either + * keyword; the counts reproduce exactly and the inference from them does not.) + * + * So the output derivation stays the served default and the authoring + * derivation is a RETRY, reached only when the default degenerates. Measured on + * the full registry: exactly one type — `action` — takes the retry arm, and the + * other 25 stay byte-for-byte identical. `protocol.meta-types-degenerate-derivation.test.ts` + * pins that count, which is the assertion that fails if someone later widens + * this to every type. + * + * ## Absence is deliberately NOT degeneracy + * + * A type with no Zod schema at all (`external_catalog`) never reaches here — + * the caller's `zodSchema ? ... : undefined` guard sees to that — and it is a + * different, honest shape: nothing is advertised, so nothing lies. This + * function judges only a derivation that RAN and came back empty. Widening it + * to cover missing schemas would change what absence means at the endpoint and + * is out of scope. + * + * ## A throwing conversion keeps its old path exactly + * + * If the output derivation THROWS, that is the case `HAND_CRAFTED_SCHEMAS` was + * documented for and it returns `undefined` as it always did — no retry. Not + * one type's conversion throws today, so retrying there would move no payload + * while widening the change past the ruling. + * + * ## [#19295] Why both derivations carry the erased-authoring `override` + * + * The degeneracy above is a WHOLE-TYPE husk. The same erasure also happens one + * level down, per MEMBER: a predicate slot's string arm is a `ZodPipe`, so the + * output derivation describes the transform's result and the arm itself is + * served as `{}` — indistinguishable from a member that admits anything. + * {@link markErasedAuthoringInput} annotates exactly those arms; it adds a + * vendor keyword and changes no keyword zod emitted, so what each document + * ACCEPTS is untouched and the refused `io: 'input'` widening stays refused. + * + * It is passed to BOTH calls on purpose. On the authoring retry a pipe derives + * from its input side, nothing is erased, and the hook marks nothing — so a + * type served from the retry (`action`) carries a real authoring arm instead + * of a marked husk, which is the honest answer rather than a gap. + */ +const _jsonSchemaCache = new WeakMap | null>(); + +function toJsonSchemaSafe(schema: z.ZodTypeAny, typeLabel?: string): Record | undefined { + const cached = _jsonSchemaCache.get(schema); + if (cached !== undefined) return cached ?? undefined; + + let output: Record; + try { + output = z.toJSONSchema(schema, { + unrepresentable: 'any', + override: markErasedAuthoringInput, + }) as Record; + } catch { + // Conversion failed outright — the original hand-crafted-fallback case. + _jsonSchemaCache.set(schema, null); + return undefined; + } + + if (!isDegenerateDerivation(output)) { + const authorable = stripUnauthorableProperties(output); + _jsonSchemaCache.set(schema, authorable); + return authorable; + } + + // The default derivation produced a husk. Retry in the authoring shape + // before giving up — for a `ZodPipe` this is the derivation that can see + // the object at all. + try { + const authoring = z.toJSONSchema(schema, { + unrepresentable: 'any', + io: 'input', + override: markErasedAuthoringInput, + }) as Record; + if (!isDegenerateDerivation(authoring)) { + const authorable = stripUnauthorableProperties(authoring); + _jsonSchemaCache.set(schema, authorable); + return authorable; + } + } catch { + // Fall through to the loud arm below. + } + + // Both derivations are unusable. Say so once, then hand the caller + // `undefined` so its `?? HAND_CRAFTED_SCHEMAS` arm decides. + if (typeLabel && !_warnedDegenerateDerivation.has(typeLabel)) { + _warnedDegenerateDerivation.add(typeLabel); + console.warn( + `[Protocol] /meta/types: both the output and the authoring derivations of the ` + + `"${typeLabel}" schema came back empty, so no schema is derived from it — a property ` + + `panel built for this type will show no fields. Fix the schema so it derives, or ` + + `declare a HAND_CRAFTED_SCHEMAS entry for "${typeLabel}".`, + ); + } + _jsonSchemaCache.set(schema, null); + return undefined; +} + +/** + * Hand-crafted fallback JSON Schemas for metadata types whose Zod schema + * cannot be safely converted via `z.toJSONSchema()` (e.g. due to recursive + * references or non-representable constructs like `z.lazy()` chains). + * + * These mirror the shape consumed by the corresponding `*.form.ts` layouts, + * so the SchemaForm renderer can still produce a real form (instead of + * falling back to the raw JSON editor). All fields use lenient types + * (`string | object | array`) because the widget hint in the form layout + * is what actually drives the UI control selection — the JSON Schema is + * only used to (a) seed defaults and (b) report which property names exist. + */ +const HAND_CRAFTED_SCHEMAS: Record> = { + object: { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + pluralLabel: { type: 'string' }, + icon: { type: 'string' }, + description: { type: 'string' }, + isSystem: { type: 'boolean', default: false }, + datasource: { type: 'string' }, + fields: { + // Canonical Object.fields is a name-keyed map + // (Record) — insertion order is + // display order. The SchemaForm engine recognises + // `additionalProperties` as a Record and dispatches to + // the `record` form-field renderer (ADR-0007). The form + // layout in `object.form.ts` declares `type: 'record'` + // so the inner `additionalProperties` schema is used to + // shape each value. + type: 'object', + default: {}, + additionalProperties: { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string' }, + required: { type: 'boolean', default: false }, + unique: { type: 'boolean', default: false }, + defaultValue: {}, + description: { type: 'string' }, + }, + required: ['type'], + }, + }, + capabilities: { type: 'object', additionalProperties: true }, + }, + required: ['name'], + additionalProperties: true, + }, + action: { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + objectName: { type: 'string' }, + icon: { type: 'string' }, + type: { type: 'string', enum: ['url', 'flow', 'api', 'script'] }, + variant: { type: 'string', enum: ['primary', 'secondary', 'danger', 'ghost', 'outline'] }, + target: { type: 'string' }, + method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, + body: { + type: 'array', + default: [], + items: { + type: 'object', + properties: { + line: { type: 'string' }, + }, + }, + }, + params: { + type: 'array', + default: [], + items: { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string' }, + required: { type: 'boolean', default: false }, + }, + required: ['name'], + }, + }, + confirmText: { type: 'string' }, + successMessage: { type: 'string' }, + refreshAfter: { type: 'boolean', default: true }, + locations: { + type: 'array', + default: [], + items: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + }, + }, + component: { type: 'string' }, + visible: { type: 'string' }, + disabled: { type: 'string' }, + // No `shortcut` / `bulkEnabled`: spec 17 retired both as + // `retiredKey()` tombstones, so authoring either is a hard parse + // rejection. This schema is what the Studio designer renders its + // fallback form from, so leaving them here handed authors two + // inputs that could only ever produce an unsaveable draft + // (objectui#3145 removed the matching dedicated controls). + // `bulkEnabled`'s replacement is the list view's `bulkActions` / + // `bulkActionDefs`; `shortcut` has none. + aiExposed: { type: 'boolean', default: false }, + recordIdParam: { type: 'string' }, + recordIdField: { type: 'string' }, + bodyShape: { type: 'string', enum: ['flat', 'nested'] }, + }, + required: ['name', 'label', 'type'], + additionalProperties: true, + }, + // ADR-0088 (#4509): the `validation` kind is retired, so its hand-crafted + // form goes with it. Rules are authored inside `object.validations[]` and + // edited on the object; there is no standalone validation editor to render + // a schema for. (The form was flat by necessity — ValidationRuleSchema is a + // 6-variant discriminated union the generic SchemaForm treats as opaque — + // and it had no field for the object being validated, which is precisely + // the gap that retired the kind: a rule saved here bound to nothing.) +}; + +/** + * Zod schemas used to validate overlay items before they are persisted into + * `sys_metadata` by {@link ObjectStackProtocolImplementation.saveMetaItem}. + * + * Single source of truth: the spec-side {@link getMetadataTypeSchema} + * registry (`@objectstack/spec/kernel/metadata-type-schemas`). Every + * metadata type whose payload should round-trip through Studio's + * generic editor maps to its canonical Zod schema there; this function + * is a plural→singular adapter on top of it. + * + * Validation policy: + * - `safeParse` is used so we can craft a 422 with structured `issues`. + * - [#20051] What is persisted depends on the type — ADR-0005 appendix (c), + * the "Addendum — 2026-05-16 (c): spec validation on overlay save": + * - `view` stores the parsed value of every key its body carried: + * undeclared keys are dropped, schema defaults are not materialised + * (ADR-0087's `storable` rule, the one flows follow). Every key the + * console reads back off a stored row is declared on the contract + * (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), so the parse keeps it. See + * {@link projectStorableViewBody}. + * - Every other type stores its original payload verbatim, with two + * normalizations grafted back from `parsed.data` so a save stops + * minting legacy-alias rows — filter `operator` spellings + * ({@link graftNormalizedOperators}) and the form `groups` → `sections` + * move ({@link graftFoldedFormSections}). [#20101] A page that omits + * `type` is stored with the default `PageSchema` declares for it — see + * {@link withDeclaredPageTypeDefault}. + * - Types without a registered schema (the wiring-layer types + * `function`/`service`/`router`, and any plugin types that have not + * yet called `registerMetadataTypeSchema()`) fall through unvalidated. + */ +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; +} + +/** + * [ADR-0131 D6, ADR-0010 Phase 4.3] The schema the save door judges `key` by on + * an item of `type` — read off {@link resolveOverlaySchema}, the door's own + * resolver, so no list of which types declare which key is kept anywhere — or + * `undefined` when that schema declares no such key (or the type has none). + * + * Read structurally: the object shapes the schema parses to, through the + * wrappers the registered types use (lazy, pipe, union, intersection and the + * optional family). A union declares the key when any member does. A schema + * this walk cannot read answers `undefined`, which leaves the key to the save + * door's own verdict — the direction that changes no refusal. + * + * The unmanaged import asks it for `protection`: only a type that declares the + * block has one to strip (`stripUnmanagedImportLock`); on any other type the + * save door judges the block exactly as it did before. + */ +export function overlaySchemaDeclaredKey(type: string, key: string): z.ZodTypeAny | undefined { + return declaredKeySchema(resolveOverlaySchema(type, undefined), key, 0); +} + +function declaredKeySchema(schema: unknown, key: string, depth: number): z.ZodTypeAny | undefined { + if (!schema || depth > 12) return undefined; + const def = (schema as { _zod?: { def?: Record } })._zod?.def; + switch (def?.type) { + case 'object': { + const shape = (schema as { shape?: Record }).shape ?? def.shape ?? {}; + return Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] as z.ZodTypeAny : undefined; + } + case 'lazy': + return declaredKeySchema(def.getter?.(), key, depth + 1); + case 'pipe': + return declaredKeySchema(def.in, key, depth + 1) ?? declaredKeySchema(def.out, key, depth + 1); + case 'intersection': + return declaredKeySchema(def.left, key, depth + 1) ?? declaredKeySchema(def.right, key, depth + 1); + case 'union': + for (const option of (def.options ?? []) as unknown[]) { + const found = declaredKeySchema(option, key, depth + 1); + if (found) return found; + } + return undefined; + case 'optional': + case 'nullable': + case 'default': + case 'prefault': + case 'readonly': + case 'nonoptional': + case 'catch': + return declaredKeySchema(def.innerType, key, depth + 1); + default: + return undefined; + } +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { + HAND_CRAFTED_SCHEMAS, + TYPE_TO_FORM, + canonicalizeMetaRequestType, + resolveOverlaySchema, + toJsonSchemaSafe, +}; diff --git a/packages/metadata-protocol/src/protocol-metadata-issues.ts b/packages/metadata-protocol/src/protocol-metadata-issues.ts new file mode 100644 index 00000000000..c81b08cc90e --- /dev/null +++ b/packages/metadata-protocol/src/protocol-metadata-issues.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata +// issue entry and the Zod-issue walk that turns a parse failure into located metadata issues. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape + * Studio's designer keys on to highlight the offending form control. + * + * `code` is zod's OWN issue code, passed through verbatim. That is deliberately + * NOT the ADR-0114 `fields[]` catalog the data surface speaks: this envelope is + * a metadata-authoring diagnostic, its consumers already read raw zod codes, and + * aligning the two vocabularies is a separate decision (#5364). + */ +export interface MetadataIssueEntry { + path: string; + message: string; + code: string | undefined; +} + +/** + * How many levels of nested issues are expanded below a top-level issue, and + * how many equally-informative branches are emitted at one level. + * + * Both bounds — and the whole selection policy below — are the ones + * `formatZodError` landed for the CLI/spec side of this defect (#4971, + * `spec/src/shared/error-map.zod.ts`) and `zodIssuesToFields` landed for the + * REST wire (#5014, `rest/src/rest-server.ts`). This is the THIRD copy: spec + * exports only the STRING renderer and rest's version emits ADR-0114 + * `{field, code}` catalog entries, while this envelope is + * `{path, message, code}` with zod's raw code. The *verdict* must match all the + * same, or one mistake gets three different prescriptions depending on whether + * the author published from the terminal, POSTed to the data API, or saved from + * Studio (#5364). + * + * [#8783] The depth bound is named for what it bounds — nesting — and not for + * `invalid_union`, because it now bounds the container descent below as well. + * The spec pair renamed the same constant for the same reason when #8318 + * extracted it (`NESTED_EXPANSION_DEPTH_LIMIT` in + * `spec/src/shared/union-branch-policy.ts`); the value is the same 3, and it + * has to be, for the reason in the paragraph above. + */ +const NESTED_EXPANSION_DEPTH_LIMIT = 3; + +const UNION_BRANCH_EMIT_LIMIT = 3; + +/** + * [#5389/#8783] The issue codes that hang their real diagnosis on + * `issue.issues` rather than on `invalid_union`'s `issue.errors`. + * + * Zod raises these when a CONTAINER's inner schema rejects a key or an element + * that cannot be addressed by a path segment: + * + * - `invalid_key` — `z.record(K, V)`'s **key** schema rejected a key, and + * `z.map(K, V)`'s key schema rejected a non-`PropertyKey` key; + * - `invalid_element` — `z.map(K, V)`'s **value** schema rejected the value + * under a non-`PropertyKey` key. + * + * In both cases the issue's own `message` is a bare wrapper (`"Invalid key in + * record"`) and everything the author needs sits one level down, exactly as + * `invalid_union` hides a branch's prescription in `errors` (#4971). Without + * the descent this envelope stopped at the wrapper while the other two walks + * descended, so the single most-authored metadata type's commonest authoring + * mistake — `ObjectSchema.fields` is a record whose KEY schema carries the + * snake_case rule (`spec/src/data/object.zod.ts`) — reached Studio as + * `{path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in + * record'}` with the sentence naming the rule dropped (#8783). + * + * ⚠️ These issues are NOT ranked, and that is the one structural difference + * from the union descent below. A union's branches are competing *candidates*, + * so they are selected between; a container has one inner schema, so every + * issue it produced is a true statement about the value and dropping any would + * be dropping a real diagnosis. Hence the plain loop rather than + * {@link selectUnionBranches}. + * + * ⛔ Deliberately a local declaration, not an import. `spec`'s + * `CONTAINER_ISSUE_CODES` is package-internal by decision (the #4001 export + * pitfall) and reaching for it would be #8660's option 2 — a public-surface + * decision this change does not make. `union-branch-policy.cross-package-parity.test.ts` + * §1 is the tripwire that keeps that decision explicit, and §5 there pins this + * set's *behaviour* against both spec walks. + */ +const CONTAINER_ISSUE_CODES: ReadonlySet = new Set(['invalid_key', 'invalid_element']); + +/** + * A zod issue, as much of it as the expansion reads. + * + * `errors` exists only on `invalid_union`: one issue list **per union branch**, + * with each branch's paths RELATIVE to the union issue's own path. Zod raises a + * single `invalid_union` issue whose own `message` is the literal + * `"Invalid input"`, so everything a failing branch has to say lives down there. + * + * `issues` is the container equivalent (#8783), carried by the + * {@link CONTAINER_ISSUE_CODES}: the one issue list the failing key/element + * schema produced, with paths relative to this issue's own — the same + * relationship `errors` has, spelled with a different property name and without + * the per-branch nesting, because a container has one inner schema rather than + * N alternatives. + */ +interface ZodIssueLike { + path?: unknown; + message?: unknown; + code?: unknown; + errors?: unknown; + issues?: unknown; +} + +/** A zod issue path, normalised to the array zod always produces. */ +function issuePathOf(issue: ZodIssueLike): Array { + return Array.isArray(issue?.path) ? (issue.path as Array) : []; +} + +/** + * True when a branch only complains that the value is the wrong *kind* at the + * branch root — `expected string, received object` for the string member of + * `z.union([z.string(), SomeObject])`. + * + * Such a branch carries no prescription: the author never intended it, and + * emitting it is the "N branches, N times the noise" failure. An empty branch + * (zod's "matched multiple" variant carries `errors: []`) counts as + * uninformative too — `every` on an empty list is `true`. + */ +function isKindMismatchOnly(issues: readonly ZodIssueLike[]): boolean { + return issues.every( + (issue) => + issuePathOf(issue).length === 0 + && (issue?.code === 'invalid_type' || issue?.code === 'invalid_value'), + ); +} + +/** True when a branch carries the #4001 campaign's unknown-key prescription. */ +function carriesUnknownKey(issues: readonly ZodIssueLike[]): boolean { + return issues.some((issue) => issue?.code === 'unrecognized_keys'); +} + +/** + * Pick the branch(es) of a failed union whose issues actually explain the + * failure. Ranking, in order (identical to `selectUnionBranches` in + * `spec/src/shared/error-map.zod.ts` and `rest/src/rest-server.ts`): + * + * 1. **Kind-mismatch-only branches are dropped entirely.** If *every* branch is + * one — a plain `z.union([z.string(), z.number()])` handed an object — + * nothing is selected and the union reports exactly what it always has. + * 2. **Fewest issues wins.** The branch the author was closest to hitting + * complains least, so "fewest" is what keeps ONE unknown key from arriving as + * N `issues[]` entries, one per branch. + * 3. **A branch carrying `unrecognized_keys` breaks a tie**, because that is + * where the curated prose lives. + * 4. Declaration order breaks what remains, so the envelope is deterministic. + * + * Branches that tie at the top are all emitted (capped): when two shapes explain + * the failure equally well, privileging the first by accident of declaration + * order would be a lie about which shape was expected. + */ +function selectUnionBranches( + branches: readonly (readonly ZodIssueLike[])[], +): readonly (readonly ZodIssueLike[])[] { + const informative = branches + .map((issues, index) => ({ issues, index })) + .filter((branch) => !isKindMismatchOnly(branch.issues)); + if (informative.length === 0) return []; + + const rank = (branch: { issues: readonly ZodIssueLike[] }): [number, number] => [ + branch.issues.length, + carriesUnknownKey(branch.issues) ? 0 : 1, + ]; + + const sorted = [...informative].sort((a, b) => { + const [aCount, aKeys] = rank(a); + const [bCount, bKeys] = rank(b); + return aCount - bCount || aKeys - bKeys || a.index - b.index; + }); + + const [bestCount, bestKeys] = rank(sorted[0]!); + return sorted + .filter((branch) => { + const [count, keys] = rank(branch); + return count === bestCount && keys === bestKeys; + }) + .slice(0, UNION_BRANCH_EMIT_LIMIT) + .map((branch) => branch.issues); +} + +/** + * One issue → its `issues[]` entries, appended to `out`. + * + * An ordinary issue is one entry. An `invalid_union` is its own entry (zod's + * bare `"Invalid input"`) FOLLOWED by the entries of the branches that explain + * it, with `path` resolved against the union's own — branch paths are RELATIVE + * to it, which is the trap #5014 paid for: a branch issue's `path` names a slot + * inside the union member, not inside the document. + * + * [#8783] A {@link CONTAINER_ISSUE_CODES} issue descends the same way and for + * the same reason, into `issue.issues` instead of `issue.errors`, with two + * differences: nothing is ranked (a container has one inner schema, so every + * issue it raised is true — see the note on the code set), and the descent is + * only taken when the issue's own code says the payload is a container's. That + * second half is what keeps this targeted: an `issues` array hanging off any + * other code is left alone, so the walk did not become "descend whatever + * nests". + * + * The wrapper's entry is kept rather than replaced — for the union and the + * container alike: it is the only entry naming the slot the client sent, + * existing consumers already read it, and when nothing below is informative it + * is still the whole answer. So the expansion is strictly ADDITIVE — no entry + * that shipped before this changed is gone or renumbered, only newly + * accompanied. Both spec walks were measured to do the same before #8783 copied + * them (`formatZodIssue` prints the wrapper line then the indented detail; + * `zodIssuesToFields` emits the `invalid_shape` wrapper then the detail entry). + * + * `seen` de-duplicates entries *within one top-level issue*: two branches that + * reject the same key with the same words say it once. Expanded entries — union + * and container heads alike — are exempt, since two same-path wrapper entries + * can head genuinely different sub-trees. + * + * Deliberate divergence from the spec-side renderer: where it prints a trailing + * "… and N more branches rejected this value", this emits nothing. That line is + * a rendering affordance for a terminal; an `issues[]` entry is a machine-read + * record that must name a slot and carry a code, and the omission note has + * neither. + */ +function collectMetadataIssues( + issue: ZodIssueLike, + parentPath: Array, + depth: number, + seen: Set, + out: MetadataIssueEntry[], +): void { + const path = [...parentPath, ...issuePathOf(issue)]; + const branches: readonly (readonly ZodIssueLike[])[] = + issue?.code === 'invalid_union' && Array.isArray(issue?.errors) + ? (issue.errors as unknown[]).filter( + (branch): branch is ZodIssueLike[] => Array.isArray(branch), + ) + : []; + const contained: readonly ZodIssueLike[] = + typeof issue?.code === 'string' && CONTAINER_ISSUE_CODES.has(issue.code) + && Array.isArray(issue?.issues) + ? (issue.issues as ZodIssueLike[]) + : []; + const expandable = (branches.length > 0 || contained.length > 0) + && depth < NESTED_EXPANSION_DEPTH_LIMIT; + + const entry: MetadataIssueEntry = { + path: path.join('.'), + message: String(issue?.message ?? 'Invalid value'), + code: issue?.code === undefined ? undefined : String(issue.code), + }; + + if (!expandable) { + const key = JSON.stringify([entry.path, entry.code, entry.message]); + if (seen.has(key)) return; + seen.add(key); + } + out.push(entry); + if (!expandable) return; + + if (branches.length > 0) { + for (const branch of selectUnionBranches(branches)) { + for (const nested of branch) { + collectMetadataIssues(nested, path, depth + 1, seen, out); + } + } + return; + } + + // Unranked, and every one of them: see {@link CONTAINER_ISSUE_CODES}. + for (const nested of contained) { + collectMetadataIssues(nested, path, depth + 1, seen, out); + } +} + +/** + * Zod issues → the `422 INVALID_METADATA` envelope's `issues[]`. + * + * A rejection behind a `z.union` is expanded (#5364): zod folds every branch of + * a failed union into ONE top-level issue whose message is the literal + * `"Invalid input"`, so mapping only top-level issues put + * `[{path: '', message: 'Invalid input', code: 'invalid_union'}]` on the wire — + * not one field name — while the branch that says WHICH key is wrong (the #4001 + * curated unknown-key prose, the legal enum of a mistyped discriminator) was + * produced and dropped at the `.map()`. + * + * That mattered most HERE of the four consumers of this defect: `ViewMetadataSchema` + * is itself a top-level union, so EVERY failed `view` save degraded to that one + * rootless line and Studio's form had nothing to highlight. The other three — + * `formatZodError` (#4971), `zodIssuesToFields` (#5014), the CLI's + * `formatZodErrors` (#5341) — lost prescriptions; this one lost field + * localisation itself. + * + * A rejection inside a CONTAINER is expanded for the same reason (#8783): zod + * hangs a `z.record`/`z.map` key or element rejection on + * {@link CONTAINER_ISSUE_CODES} with the bare wrapper `"Invalid key in record"` + * as its own message and the real rule one level down in `issue.issues`. Both + * spec walks have descended those since #5389 while this one stopped at the + * wrapper, so an author who typed `firstName` for a field key was told a key + * was invalid and never told what a valid one looks like — the declared + * snake_case prescription exists, is correct, and simply did not reach the + * author. + * + * Branch selection is described on {@link selectUnionBranches} and is identical + * to the other copies by construction; the container descent is described on + * {@link CONTAINER_ISSUE_CODES}. + */ +export function zodIssuesToMetadataIssues(issues: unknown): MetadataIssueEntry[] { + if (!Array.isArray(issues)) return []; + const out: MetadataIssueEntry[] = []; + for (const issue of issues) { + // A fresh `seen` per top-level issue: de-duplication is about one + // union's branches agreeing, never about two independent issues. + collectMetadataIssues(issue as ZodIssueLike, [], 0, new Set(), out); + } + return out; +} diff --git a/packages/metadata-protocol/src/protocol-mutation-events.ts b/packages/metadata-protocol/src/protocol-mutation-events.ts new file mode 100644 index 00000000000..a7f2511fbeb --- /dev/null +++ b/packages/metadata-protocol/src/protocol-mutation-events.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata +// mutation and publish event contracts, the cluster channel, and the mutation projector. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * Post-persistence metadata-mutation notification (#2588). Emitted by + * `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write + * landed. `type` is the singular metadata type name. Subscribe via + * {@link ObjectStackProtocolImplementation.onMetadataMutation}. + */ +export interface MetadataMutationEvent { + type: string; + name: string; + /** Resulting lifecycle state of the row the mutation produced. */ + state: 'active' | 'draft' | 'deleted'; + organizationId?: string | null; + /** + * [#22877] The bundle member the mutated row belongs to, for a type whose + * identity is a pair (`'zh-CN'` of an `email_template`) — part of the + * row's ADDRESS, so a peer converging on this signal re-reads that + * member's row, not the canonical one. Absent for the canonical member and + * for every type with no members, as on every by-name door. + */ + member?: string; +} + +/** + * [#13331] Cluster channel for post-persistence metadata mutations — the + * cross-node half of {@link MetadataMutationEvent}. + * + * Published by the protocol that performed the write (see + * `attachMetadataMutationPubSub`) and consumed by every peer replica to + * CONVERGE its in-memory registry from its OWN `sys_metadata` read. Distinct + * from `MetadataManager`'s `metadata.changed` channel on purpose: that one + * replays watch events into the metadata SERVICE's caches, while this one + * repairs the ObjectQL engine registry behind the data plane — the state the + * runtime authoring path (`saveMetaItem` → `applyRegistryWriteThrough`) + * mutates without ever touching the metadata service. + */ +export const METADATA_MUTATION_CLUSTER_CHANNEL = 'metadata.mutated'; + +/** + * [#13331] Payload for {@link METADATA_MUTATION_CLUSTER_CHANNEL}. + * + * ⭐ A SIGNAL, never trusted content (ruled 2026-09-01): the receiving replica + * re-reads the row from its own `sys_metadata` and re-runs the registry + * write-through from that read, so the shared database stays the single + * source of truth. `event` carries only the row's ADDRESS (type/name/state/ + * org scope); no body ever rides this channel, which is also what makes + * at-least-once delivery and duplicates harmless by construction — replaying + * the same address converges to the same registry state. + */ +export interface ClusterMetadataMutationPayload { + /** Origin nodeId — used for loopback suppression. */ + originNode?: string; + /** The post-persistence mutation notification, address-only. */ + event: MetadataMutationEvent; +} + +/** + * [#10219] A single item reached `active` through the per-item publish door + * (`publishMetaItem`, i.e. `POST /api/v1/meta/:type/:name/publish`). `type` is + * the CANONICAL singular metadata type name — the same spelling the promoted + * row carries — so a subscriber can build the `'{type}/{name}'` entry the + * `metadata:reloaded` payload's `changed` list is made of. + * + * Subscribe via {@link ObjectStackProtocolImplementation.onMetaItemPublished}. + * + * ## Why this is NOT the existing {@link MetadataMutationEvent} + * + * The per-item publish emits BOTH. The mutation event says "a row changed" and + * is emitted from `runPublishSideEffects`, the phase-2 helper the BATCH door + * (`publishPackageDrafts`) runs once per promoted draft as well — so a + * subscriber that announced a full metadata reload on it would fire N times for + * one "publish whole app", each announce driving a complete re-sync (schema DDL, + * connector re-materialization, flow re-bind) for every other item in the same + * batch. This event is emitted ONLY by the single-item door, which is the one + * the batch route's own announce does not cover. + */ +export interface MetaItemPublishedEvent { + /** Canonical singular metadata type of the promoted item. */ + type: string; + name: string; + /** Scope the promotion landed in — `null` is env-wide. */ + organizationId: string | null; +} + +/** + * Awaited per-type mutation projector (ADR-0094). Invoked AFTER a metadata + * mutation persists — `saveMetaItem` (draft AND active saves), + * `publishMetaItem`, `deleteMetaItem` — and AWAITED before the write returns, + * so a data-plane read-model derived from the metadata (e.g. the + * `datasource` projector's) is already consistent when the caller's next read + * lands. This is what makes such a read-model a PURE projection: the + * projector is its only writer, and it runs in the same awaited operation as + * every metadata write, instead of a fire-and-forget subscriber a new write + * path might race or forget. + * + * Complements (does not replace) {@link MetadataMutationEvent} listeners, + * which stay fire-and-forget for cache-invalidation consumers. + * + * Best-effort: a projector failure is surfaced on the write's response + * (`projectionApplied: { success:false, error }`) and logged, never thrown — + * the metadata write itself already succeeded, and boot reconciliation heals + * the projection on next start. + * + * `body` carries the just-persisted item when the mutation has one in hand + * (save/publish); projectors that need the EFFECTIVE (layered) body should + * re-read it — a delete, for instance, may reveal the artifact baseline. + */ +export type MetadataMutationProjector = ( + evt: MetadataMutationEvent & { body?: unknown }, +) => Promise; + +/** Per-write outcome of the awaited mutation projector (ADR-0094). */ +export interface MutationProjectionOutcome { + success: boolean; + error?: string; +} diff --git a/packages/metadata-protocol/src/protocol-overlay-merge.ts b/packages/metadata-protocol/src/protocol-overlay-merge.ts new file mode 100644 index 00000000000..5f936a99840 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-overlay-merge.ts @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the served +// overlay row candidates and the package-aware overlay merge. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + itemDiscriminator, +} from '@objectstack/metadata-core'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { customizationPackageId } from '@objectstack/spec/kernel'; +import { isServedOverrideRowOf } from './served-override.js'; +import { + listSlotNameOf, +} from './protocol-object-extension-item.js'; +import { withRowBinding } from './protocol-package-bindings.js'; + +/** + * One place a stored `sys_metadata` row of an item can sit, as + * {@link servedOverlayRowCandidates} orders them: a spelling of the type + * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 + * residue), and the package the row is bound to: the address's package, `null` + * for the package-less row, or `undefined` for any row (an address that names + * no package). Every row in a candidate's place is the environment's + * (ADR-0131 D6/D7: the reads leave a legacy organization's rows out). + */ +interface ServedOverlayRowCandidate { + readonly spelling: 'canonical' | 'other'; + readonly packageId: string | null | undefined; + /** + * [#28409, ADR-0146 D8 item 2] Set on the one candidate that is a base's + * declared override: the base package whose name the row must override. + * `packageId` is then the base's pair (`.custom`), and a row there + * holds the candidate only when it declares the override + * ({@link isServedOverrideRowOf}); an undeclared row of the pair is that + * package's own item, never the base's. + */ + readonly overrideOf?: string; +} + +/** + * [#21804] Where a stored row the list read sits, beside the package it is + * bound to: the type spelling it is stored under (the row's own `type` + * column). + */ +interface StoredRowPlace { + readonly type: string; +} + +/** + * [#21804, ADR-0131 D6, ADR-0048] THE served-row resolution: the order in + * which the stored rows of one item are tried for the CONTENT a read serves. + * The first candidate that holds a row is the served row. + * + * - Scope (ADR-0131 D6): the environment's rows only. A legacy + * organization-scoped row is refused at boot + * (the v18 boot refusal, ADR-0131 D10 item 5) + * and never served, by any read: the anonymous form doors' read of the + * Default Organization's legacy layer was deleted when the v18 migration + * ceremony began carrying those rows' withdrawals into the environment + * (ADR-0131 C7, rulings A on #22008 and #22011). + * - Then the spelling: the canonical type, then the other spelling (the + * at-rest tolerance the reads have always had, #4432). + * - Then the package (ADR-0048 prefer-local): with a `packageId`, that + * package's own row, then the package-less row, never another package's; + * with none, any row. + * - [#28409, ADR-0146 D8 item 2] …and, for a read that serves the package's + * name (`servedOverride`), the package's declared override FIRST: the row + * of its paired `.custom` package that declares + * `overrides.package` = the package ({@link isServedOverrideRowOf}), ahead + * of the package's own row and of the package-less row. Ahead of the + * package-less row because a package-less save of such an item writes the + * pair's row whenever it is the item's only binding + * ({@link ObjectStackProtocolImplementation.packagelessAddress}, unchanged): + * serving the package-less row over it would answer a save with a body + * the save never wrote (#23165's shape). A package-unbound row still wins + * over the code package and over another package's row (ADR-0146 D8 + * item 1) until the migration moves it into the pair (D11). + * + * Both readers of stored content take this order, so they cannot pick + * differently: {@link ObjectStackProtocolImplementation.findServedOverlayRow} + * (`getMetaItem`, its draft-preview arm, `getMetaItemLayered`) asks the store + * candidate by candidate, and {@link mergePackageAwareOverlay} (the list's + * active and draft-preview merges, a list scoped to a package included, whose + * package-less rows enter as stand-ins, #21817) asks the rows it already read. Before + * #21804 the list took the LATEST of a package's row and the package-less row + * in row order, so in one order the list served the package-less body for a + * package whose by-name read served the package's own row. + * + * It decides content only. The lock is selected separately, from every row in + * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). + */ +export function servedOverlayRowCandidates(address: { + /** + * [#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; + /** + * [#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; +}): ServedOverlayRowCandidate[] { + const base = address.packageId; + const pair = address.servedOverride === true && typeof base === 'string' && base !== '' + ? customizationPackageId(base) + : undefined; + const places: ReadonlyArray> = base === null + ? [{ packageId: null }] + : base + ? [ + ...(pair?.ok ? [{ packageId: pair.value, overrideOf: base }] : []), + { packageId: base }, + { packageId: null }, + ] + : [{ packageId: undefined }]; + return (['canonical', 'other'] as const).flatMap((spelling) => + places.map((place) => ({ spelling, ...place }))); +} + +/** + * [#24371] Whether a stored row read by + * {@link ObjectStackProtocolImplementation.readItemActiveRows} is one the + * store would answer for `where`, an equality `where` of the shape the + * by-name read's selections send ({@link servedOverlayRowCandidates}' + * candidates, the package-less binding and the lock's scope): every key equal, + * and `null` matching an absent value as the store's IS NULL does. + */ +export function rowMatchesOverlayWhere(row: object, where: Readonly>): boolean { + const stored = row as Readonly>; + return Object.entries(where).every(([key, value]) => (stored[key] ?? null) === value); +} + +/** + * [#21967, ADR-0048] The package dimension of + * {@link servedOverlayRowCandidates}, in its order: with a package, that + * package (`packageId`) and then the package-less rows (`null`); with none, + * `undefined`, which matches a row of any package. + */ +export function addressPackages(packageId: string | undefined): ReadonlyArray { + return [...new Set(servedOverlayRowCandidates({ packageId }) + .map((candidate) => candidate.packageId))]; +} + +/** + * [#21967, ADR-0048] The stored view container expansion that serves `name` + * at the address of `packageId`, among `expansions` + * ({@link ObjectStackProtocolImplementation.expandStoredViewContainers}, each + * paired with the container row it came from); `undefined` when none does. + * + * The package order is the package dimension of + * {@link servedOverlayRowCandidates}, the one prefer-local resolution: with a + * package, the expansion of that package's own container row first, then the + * expansion of a package-less one, which stands in for every package with no + * container row of its own, and never another package's; with none, any + * expansion. Within one package the last expansion of the name wins, as + * before (two containers of one package that expand one name are refused at + * the save door). + * + * Both doors select through this function: the list read + * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each + * package's slot of the name, and the by-name read + * ({@link ObjectStackProtocolImplementation.resolveRowlessExpandedView}) for + * the package it names. So a package's slot in the list and the by-name read + * naming that package serve the same expansion. Before, the list upserted the + * last expansion of a name over every package's item of it, so one package's + * stored copy of a container displaced another package's item of each name + * the copy expands, a withdrawn public form included. + */ +export function servedViewExpansion( + expansions: ReadonlyArray<{ item: Record; container: E }>, + name: string, + packageId: string | undefined, +): { item: Record; container: E } | undefined { + for (const pkg of addressPackages(packageId)) { + let served: { item: Record; container: E } | undefined; + for (const expanded of expansions) { + if (expanded.item.name !== name) continue; + if (pkg === undefined || (expanded.container.packageId ?? null) === pkg) served = expanded; + } + if (served !== undefined) return served; + } + return undefined; +} + +/** + * ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list. + * + * `baseItems` (the lower layer: registry artifacts, or the running result) and + * `records` (the higher layer: active `sys_metadata` overlays, or draft rows) + * are merged so that: + * + * • Two installed packages shipping the same `type/name` stay TWO rows — + * resolution is per `(package, name)`, not bare `name`, so a higher-layer + * row no longer collapses a same-name row from a different package. + * • For each package `P` that owns a row of a given name, the winner is + * `P`'s own row or a package-less ("global", `package_id IS NULL`) row, + * the higher layer winning — `getMetaItem(name, packageId=P)`'s + * "scoped-then-global-fallback" resolution, so the list and single-item + * paths agree. This is also why a legacy row whose active/draft layers + * disagree on package attribution still collapses (a package-less active + * row + its `package_id`-bearing draft resolve to the one package slot, + * draft winning). + * • [#21804] When `records` are stored rows the caller read (each record + * carrying its `stored` place), the winner among them is the one + * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before + * the package-less one. It + * is the resolution `getMetaItem` takes, so it does not depend on the + * order the store returned the rows in. Records with no `stored` place + * (the MetadataService merge, whose `records` are already-resolved items) + * leave the LATEST contribution the winner, as before. + * • A name with NO package-owned row resolves to its latest package-less + * contribution — the pre-existing env-wide behaviour, unchanged. + * • [#21817] A list scoped to one package passes the package-less rows in + * its scope as STAND-INS (`standIn` on a record). A stand-in serves a + * slot the package seats, by the same served-row resolution, so the + * scoped list's slot is what `getMetaItem` naming the package serves: + * the package's own row, else the package-less row. A stand-in never + * seats a slot itself, so the scoped list still lists only the items the + * package ships. A slot that only stand-ins reach is emitted with its + * latest stand-in and recorded in `unseated`, and an item recorded there + * that arrives as a base item counts as a stand-in again: a later layer + * (the draft preview, the MetadataService listing) may still seat that + * slot, and the caller drops what is still recorded once the last layer + * has merged. + * + * `transform(data, prev)` runs on each `records` body before it enters the + * merge (view-identity healing, draft tagging); `prev` is the base row it + * shadows at the same slot (or any same-name base row), else undefined. + * + * [#7774] The bucket is per SLOT, not per name, and for a type in + * `ITEM_KEY_DISCRIMINATORS` the slot is `(name, discriminator)`. This is the + * half of the collapse that #7774's card predicted would need no change, and + * it needed one: the card reasoned about the OVERLAY rows (correctly — they + * are unique on `type+name+organization_id+package_id` and carry no locale + * column), but the rows are only the higher layer. `baseItems` is the + * SchemaRegistry's listing, and since #7730 that listing carries every member + * of a bundle. Bucketed by bare name, two members of one bundle landed in one + * bucket and the loop below emitted exactly one row per `(bucket, package)` — + * so a single unrelated overlay row for the type was enough to drop a locale, + * and the surviving row was the overlay body regardless of which member it + * actually customizes. Slot-keyed, the overlay lands on its OWN member and the + * others are served untouched. An undiscriminated type's slot is its name, so + * its buckets are unchanged. + * + * @param type Canonical (singular) metadata type of every row being merged. + * @param unseated [#21817] The scoped list's record of stand-ins no + * contribution has seated yet (see the stand-in bullet above). + */ +export function mergePackageAwareOverlay( + type: string, + baseItems: unknown[], + 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; + } = {}, +): unknown[] { + // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. + // `stored` marks a contribution that is a stored row the caller read; + // `standIn` one that serves a slot without seating it ([#21817]). + type Contribution = { pkg: string | undefined; item: any; stored?: StoredRowPlace; standIn?: true }; + const buckets = new Map(); + const order: string[] = []; // first-seen slot order → stable output + const slotOf = (item: unknown, name: unknown): string => { + const disc = itemDiscriminator(type, item); + return disc === undefined ? String(name) : `${String(name)}\u0000${disc}`; + }; + const push = (slot: string, pkg: string | undefined, item: any, stored?: StoredRowPlace, standIn?: boolean) => { + let list = buckets.get(slot); + if (!list) { buckets.set(slot, (list = [])); order.push(slot); } + const c: Contribution = stored ? { pkg, item, stored } : { pkg, item }; + if (standIn) c.standIn = true; + list.push(c); + }; + // [#21804] The stored row the served-row resolution picks for package + // `real` among a slot's contributions: the first candidate of + // {@link servedOverlayRowCandidates} that one of the rows sits at. + // [#28409] …and says when that row is `real`'s declared override, which + // the slot then serves under `real`'s provenance. + const servedStoredRow = ( + list: readonly Contribution[], + real: string | undefined, + ): { item: any; overrideOf?: string } | undefined => { + for (const candidate of servedOverlayRowCandidates({ packageId: real, servedOverride: options.servedOverride })) { + const hit = list.find((c) => c.stored !== undefined + && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling + && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId) + // [#28409] The pair's row holds the override candidate only + // when it declares the override: judged on the row's own + // binding (the contribution's `pkg`, the row's `package_id`) + // 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, + ))); + if (hit) return candidate.overrideOf === undefined ? { item: hit.item } : { item: hit.item, overrideOf: candidate.overrideOf }; + } + 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; + 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; + 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, bodyName); + const list = buckets.get(slot); + const prev = list + ? (list.find((c) => c.pkg === packageId)?.item + ?? list.find((c) => c.pkg === undefined)?.item + ?? list[0]?.item) + : undefined; + // [#28673] A stored row enters under its own binding, the `package_id` + // column it is slotted by ({@link withRowBinding}), so a stamp its body + // carries is never served, here or where the row stands in for a + // package's slot below. A record that is not a stored row (a caller's + // item, slotted by its own `_packageId`) enters as it is. + const bound = stored ? withRowBinding(body, packageId) : body; + push(slot, packageId, transform ? transform(bound, prev) : bound, stored, standIn); + } + + const out: unknown[] = []; + for (const slot of order) { + const list = buckets.get(slot)!; + // [#21817] Only stand-ins reach this slot: no item of the package is + // here (yet), so the stand-in is held back, not served as one. + if (list.every((c) => c.standIn)) { + // The stand-in the one candidate order picks among the stored + // rows here, else (no stored row: a stand-in held back by an + // earlier merge) the latest. + let held = servedStoredRow(list, undefined)?.item; + if (held === undefined) held = list[list.length - 1].item; + unseated?.add(held); + out.push(held); + continue; + } + // [#21817, #28409] A stand-in never seats a slot, whatever package it + // is bound to: a package-less row has none, and a pair's row that + // stands in for its base's declared override is bound to the pair. + const reals = Array.from(new Set(list.filter((c) => c.pkg !== undefined && !c.standIn).map((c) => c.pkg))); + if (reals.length === 0) { + out.push(list[list.length - 1].item); // latest package-less row wins + continue; + } + for (const real of reals) { + // getMetaItem(name, real) resolution. [#21804] A stored row is + // chosen by the served-row resolution, whatever order the rows + // came back in; with no stored row of `real`'s own or package-less, + // the latest lower-layer contribution of either stands, as before. + const served = servedStoredRow(list, real); + let chosen: any = served?.item; + if (chosen === undefined) { + for (const c of list) { + if ((c.pkg === real || c.pkg === undefined) && c.stored === undefined) chosen = c.item; + } + } + if (chosen === undefined) continue; + // A package-less body standing in for package `real` must carry + // `real`'s provenance (the base row it replaced was `real`'s). + // [#28409] So must `real`'s declared override: it is served as + // `real`'s item, under `real`'s envelope, wherever its row is bound. + if (served?.overrideOf !== undefined) chosen = { ...chosen, _packageId: real }; + else if (chosen._packageId === undefined) chosen = { ...chosen, _packageId: real }; + out.push(chosen); + } + } + return out; +} diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts new file mode 100644 index 00000000000..e062a10a8f3 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-package-bindings.ts @@ -0,0 +1,432 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: shipping +// packages, hydration options, artifact protection, row package bindings, derived provenance +// and tenant authorship. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + resolveItemLock, + storedRowDocument, + withItemLockFamily, + type ItemLock, +} from './item-lock.js'; +import { + itemDiscriminator, +} from '@objectstack/metadata-core'; +import { isServedOverrideRowOf } from './served-override.js'; +import { canonicalMetaType } from './protocol-meta-type-schema.js'; + +/** + * [#21803] The packages that can ship an item of one type, read once from the + * registry's listings: per name, and the ones probed for every name. See + * `ObjectStackProtocolImplementation.shippingPackagesOf`. + */ +export type ShippingPackages = { + readonly byName: ReadonlyMap>; + readonly anyName: ReadonlySet; +}; + +/** + * [#23198] What a write did to a view container's stored rows, as + * `ObjectStackProtocolImplementation.reconcileViewExpansions` takes it. + * + * - `binding`: one row was hydrated (a publish, a republish, a rollback, a + * revert, a replica's convergence, the boot, the list read). `binding` is + * the row's package binding, `undefined` for the package-less row, and + * `expansion` is everything that row's body expands to now. + * `reboundFrom` is set when the write moved the row from another binding + * (an orphan adoption): `reboundFrom.binding` is the binding it left, + * `undefined` for the package-less row. That binding's record moves to + * `binding`, so the row reconciles against what it registered under either. + * - `removed: 'every-row'`: a row of the name was removed (a reset, a delete, + * a revert's removal, a replica's removal). The heal is handed no binding, + * so it addresses every row of the name, and the set they imply is empty. + */ +type ViewContainerWrite = + | { + readonly container: string; + readonly binding: string | undefined; + readonly expansion: ReadonlyArray>; + readonly reboundFrom?: { readonly binding: string | undefined }; + } + | { readonly container: string; readonly removed: 'every-row' }; + +/** + * [#23514, ADR-0029 D9.9] The state a refusal of a package's object row beside + * a package-less owner (the `'sys_metadata'` sentinel) answers, which decides + * the way out its prescription names + * (`ObjectStackProtocolImplementation.unboundOwnerPackageRefusal`). + */ +interface UnboundOwnerRefusalState { + /** The save's mode; a draft save registers nothing. */ + mode?: 'draft' | 'publish'; + /** + * The packages whose ACTIVE rows of the object are stored beside the + * package-less one (none, the package the request names, or others): + * each must be deleted by naming its package, because a delete naming no + * package resolves to the one binding present, or is refused 409 over two + * ({@link ObjectStackProtocolImplementation.packagelessAddress}). + */ + storedPackages?: readonly string[]; + /** The caller named no package, and the save's address resolved to the package's row. */ + namedNoPackage?: boolean; + /** + * A publish naming no package of a draft bound to NO package, which the + * draft doors' address adopts into the one package binding stored + * (`packagelessDraftAddress`: `key: null`, `into: P`). No publish moves that + * draft into `P`: with `P`'s row gone it promotes into the package-less + * definition, and one naming `P` finds no `P` draft (`NO_DRAFT`). + */ + adoptedDraft?: boolean; + /** + * [#23712] A DRAFT save naming no package (with `namedNoPackage`): the + * packages whose drafts of the object are stored with no package-unbound + * draft beside them. Once the rows are deleted, the draft address is the + * one package's draft (`packagelessDraftAddress`), so each is discarded + * by naming its package before the save writes the package-less draft. + */ + strandedDrafts?: readonly string[]; + /** + * Where the refusal is raised: the save door (default), a draft promotion + * (the per-item publish and `publish-drafts`), or a registration of a + * stored row (boot, write-through). + */ + at?: 'save' | 'promote' | 'registry'; +} + +/** + * What a hydration of one stored row is told about it: + * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the + * expansion hydration it hands the row to. + * + * - `packageId`: the row's package binding. + * - `organizationId`: the row's scope, `null` for an environment-wide row + * (#6602). + * - `reboundFrom`: [#23198] set only when the write that is being hydrated + * moved the row from another binding (an orphan adoption). `packageId` is + * the binding it left, `null` for the package-less row. See + * {@link ViewContainerWrite}. + */ +type HydrationOptions = { + packageId?: string | null; + organizationId: string | null; + reboundFrom?: { readonly packageId: string | null }; +}; + +/** + * ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope + * onto a returned item so artifact-level lock/packageId/provenance + * always wins over whatever was persisted in the `sys_metadata` overlay + * row. Returns `item` unchanged when no artifact baseline is available. + * + * The artifact's `_packageId`, `_packageVersion` and `_provenance` are the + * source of truth — an overlay copy may pre-date the artifact's protection + * declaration and would otherwise mask it. + * + * [#21738] The lock family (`_lock`, `_lockReason`, `_lockDocsUrl`, + * `_lockSource`) is NOT decided here: it follows the one item-lock resolution + * ({@link resolveItemLock}). The artifact's family is copied over the item's + * only when the artifact's lock is the one that binds, which is exactly the + * case the sentence above exists for (an overlay never masks a packaged lock). + * An artifact that declares no lock, or an explicit `'none'`, leaves the + * item's own family in place. Before this, any declared artifact `_lock` was + * copied, `'none'` included, so an artifact's explicit `'none'` erased a + * stored row's `'full'` from the served body and from the read envelope while + * the write door, which skips `'none'`, still refused the save. + * + * [#21761, #21803] A read that resolved the item's lock from its address + * ({@link resolveArtifactLockLayer} and {@link resolveOverlayLockLayer} into + * {@link resolveItemLock}) passes that answer as `itemLock`, and the body + * carries ITS lock family ({@link withItemLockFamily}): the binding row's or + * the binding package's, which need not be the document the body was served + * from, and no `_lock*` key at all when nothing binds. `artifactItem` then + * contributes the provenance fields only: content, and with it provenance, + * stays prefer-local (ADR-0048). Without `itemLock` the item's own body + * stands in for the overlay layer over `artifactItem` alone, as before: the + * registry's hydration and a previewed draft, neither of which is a read's + * lock answer. + * + * [#23074] An artifact lends its envelope only to a body of its OWN identity. + * For a type whose identity is a pair ({@link ITEM_KEY_DISCRIMINATORS}: + * `email_template`'s `(name, locale)`), that is the same bundle member, read + * by {@link bundleMemberOf}, the identity's one declaration; an artifact of + * another member is treated as no artifact at all. So an environment overlay + * of a member the package does not ship (a `zh-CN` template beside a shipped + * `en-US`) keeps the tenant authorship it was registered with, and is never + * read as packaged code: `isCodeArtifactBody` false, no `_packageId` / + * `_packageVersion` / `_provenance` stamp, no artifact `_lock*`. Grafted, it + * read as the bundle's own artifact of that member to the registry's artifact + * lookup and its overlay-entry retirement, and the read doors served it under + * the package's provenance. The shipped member's overlay is unchanged: each + * caller looks the artifact up at the body's own member + * (`lookupArtifactItem`'s `member`), so it still wears that member's + * artifact's envelope. A registry that ignores `member` can answer another + * member's artifact, and this check is what keeps that answer off the body. + * Every other type has no members, so `bundleMemberOf` is `undefined` on both + * sides and the merge is exactly what it was. ⛔ The lock family a read + * resolved (`itemLock`) is applied before this check and is not moved by it: + * a lock binds the NAME, at the read and at the `_lock` gate alike + * ({@link resolveItemLock} over every package that ships the name). + */ +function mergeArtifactProtection( + type: string, + item: unknown, + artifactItem: unknown, + itemLock?: ItemLock, +): unknown { + if (item === undefined || item === null) return item; + if (itemLock !== undefined) item = withItemLockFamily(item, itemLock); + if (artifactItem === undefined || artifactItem === null) return item; + const a = artifactItem as Record; + if (typeof a !== 'object') return item; + if (bundleMemberOf(type, a) !== bundleMemberOf(type, item)) return item; + const out: Record = { ...(item as Record) }; + if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') { + if (a._lock !== undefined) out._lock = a._lock; + if (a._lockReason !== undefined) out._lockReason = a._lockReason; + if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl; + if (a._lockSource !== undefined) out._lockSource = a._lockSource; + } + if (a._packageId !== undefined) out._packageId = a._packageId; + if (a._packageVersion !== undefined) out._packageVersion = a._packageVersion; + if (a._provenance !== undefined) out._provenance = a._provenance; + return out; +} + +/** + * [#23074] The bundle member a body IS, for a type whose identity is a pair + * ({@link ITEM_KEY_DISCRIMINATORS}): `itemDiscriminator` under the type's + * canonical spelling, so the canonical member for a body that declares none. + * `undefined` for every other type, which has no members. The member + * {@link mergeArtifactProtection} matches an artifact against, and the one + * each of its callers looks the artifact up at. + */ +function bundleMemberOf(type: string, body: unknown): string | undefined { + return itemDiscriminator(canonicalMetaType(type), body); +} + +/** + * [#22024, ADR-0048, ADR-0010 §3.3] The package whose artifact envelope + * ({@link mergeArtifactProtection}: `_packageId`, `_packageVersion`, + * `_provenance`) a served item wears: the package the read names, else the + * package the served item itself is bound to, as the step that served it + * stamped it (a stored row's `package_id`, a container expansion's own + * package, the MetadataService's or the registry's item's `_packageId`). + * + * The one rule both read doors take: the list + * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each + * item it serves, and the by-name read + * ({@link ObjectStackProtocolImplementation.getMetaItem}) for the item it + * serves. So one served body wears one envelope on both doors. + * + * A read naming no package used to take the envelope of the first composite + * the registry holds under the name, which is the first-registered package's. + * Where two packages ship the name and the body served is another package's + * (its stored row, or its stored copy of a container they both ship), the + * answer served that package's body under the first-registered package's + * `_packageId`. A served item bound to no package (a package-less stored row, + * a registry entry with no package) names none, so its lookup is the + * package-less one it always was. ⛔ The lock is not chosen here: it is the + * one item-lock resolution's ({@link resolveItemLock}), from the read's own + * address. + */ +function envelopePackageId(requestedPackageId: string | undefined, served: unknown): string | undefined { + return requestedPackageId ?? (served as { _packageId?: string } | null | undefined)?._packageId; +} + +/** + * [#28831, #22024, ADR-0146 D8] The artifact envelope an item of the + * per-package layer ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) + * wears: the artifact the read looked up at the item's package + * ({@link envelopePackageId}) when it is that package's OWN, else none. + * + * The artifact lookup falls back to the first code package that ships the + * name when the item's package ships none (`registry.getArtifactItem`'s + * any-composite tier). On the effective read that is how a package-less row + * standing in for a base wears the base's envelope. On the per-package layer + * every row is seated under its own package, so the fallback stamped a + * dependent package's row, or a `.custom` pair's undeclared row, under a base + * item's name with the BASE's `_packageId`, `_packageVersion` and + * `_provenance`: the export and the anonymous form doors' withdrawal layer + * read it as the base's. + * + * One exception keeps today's stamp: a stored row of the item's package that + * is the served override of the artifact's package, judged by + * {@link isServedOverrideRowOf} on the row's own `package_id` (never a body + * `_packageId`) at the item's own bundle member. It is served as its base's + * item, under its base's envelope. + * + * An item bound to no package (`boundPackageOf` answers `undefined`) keeps + * the lookup's answer, as before. ⛔ The lookup itself is not narrowed: its + * other callers rely on the fallback. + */ +function perPackageLayerEnvelope( + type: string, + item: unknown, + itemPackageId: string | undefined, + artifact: unknown, + rowsOfName: readonly any[], +): unknown { + if (artifact === undefined || artifact === null || typeof artifact !== 'object') return artifact; + const own = boundPackageOf(itemPackageId); + if (own === undefined) return artifact; + const lender = (artifact as { _packageId?: unknown })._packageId; + 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; + try { + return bundleMemberOf(type, storedRowDocument(row)) === member; + } catch { + return false; + } + }); + return declared ? artifact : undefined; +} + +/** + * [#22057] The package a body is bound to, when it is bound to one: a + * non-empty id that is not the `'sys_metadata'` sentinel a package-less row + * carries. `undefined` for a package-less body. + */ +function boundPackageOf(packageId: unknown): string | undefined { + return typeof packageId === 'string' && packageId !== '' && packageId !== 'sys_metadata' ? packageId : undefined; +} + +/** + * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE + * DERIVED, and therefore must never be persisted from a caller's body. + * + * {@link mergeArtifactProtection} recomputes all three from the artifact on + * every read, so a copy stored inside a `sys_metadata` body is never + * load-bearing: removing it is observable ONLY where that copy was a lie. + * + * ⛔ The `_lock*` family is deliberately NOT here, though it shares the + * underscore spelling and the same ADR-0010 envelope. A lock is + * AUTHOR-DECLARABLE (`protection.lock`, translated into `_lock*` by + * `applyProtection`), so dropping one is the FAIL-OPEN direction — cloud PR + * #2065 drew that line at its own producer and this door follows it. + * + * ⛔ Nor is this a second `METADATA_READ_DECORATIONS`. That list is shared + * with every consumer that re-parses a SERVED document (`spec`'s + * `metadata-read-decorations.ts`), and its header states on purpose that the + * protection envelope stays on a served body so provenance survives a + * re-parse. This strip is scoped to the WRITE door alone. + */ +const DERIVED_PROVENANCE_KEYS = ['_packageId', '_packageVersion', '_provenance'] as const; + +/** + * [#16702] Remove {@link DERIVED_PROVENANCE_KEYS} from a body about to be + * PERSISTED into `sys_metadata`. + * + * A **silent** strip, for the same reason {@link stripReadDecorations} is + * silent: the standard Studio `GET` → edit → `PUT` round-trip echoes whatever + * the served document carried, and refusing that round-trip would be hostile + * for keys the server stamped itself. What it restores is the invariant the + * `_provenance: 'org'` docblock in {@link + * ObjectStackProtocolImplementation.applyObjectRegistryMutation} already + * states: every row this door writes is tenant-authored by definition, so the + * server states that fact rather than reading it back from the caller. + * + * Returns the SAME reference when there is nothing to strip, so the common + * path allocates nothing. Non-object inputs pass through — the caller's own + * validation owns those. + */ +function stripDerivedProvenance(item: unknown): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const dict = item as Record; + if (!DERIVED_PROVENANCE_KEYS.some((k) => k in dict)) return item; + const next = { ...dict }; + for (const k of DERIVED_PROVENANCE_KEYS) delete next[k]; + return next; +} + +/** + * [#16702] State what every `sys_metadata` row IS — tenant-authored, ADR-0010 + * `_provenance: 'org'` — on a COPY of a body about to be REGISTERED. + * + * The same sentence {@link + * ObjectStackProtocolImplementation.applyObjectRegistryMutation} and the boot + * `object` limb already write, said once for every OTHER type at the one + * hydration choke point they do not share. Without it the row's own bytes + * decide: `isCodeArtifactBody` accepts a truthy non-sentinel `_packageId` with + * non-`org` provenance, `SchemaRegistry.getArtifactItem`'s bare-key fallback + * returns the overlay AS an artifact, `isArtifactBacked` turns true, and + * `saveMetaItem`'s overlay gate refuses the tenant's next write to their own + * item with `NOT_OVERRIDABLE` — permanently, because the next boot re-derives + * the same verdict from the same row (cloud#970's shape, for non-`object` + * types). + * + * ⚠️ Both callers — the container in `hydrateOverlayIntoRegistry` and, since + * #21511, each view expansion it registers (`expandRuntimeViewContainer` + * under `tenantAuthored`) — apply it BEFORE {@link mergeArtifactProtection}, + * and the order is the whole contract: where a real artifact exists the artifact's + * envelope still overwrites `_provenance` (and `_packageId` / + * `_packageVersion` / `_lock*`) on the way out, so package protection is + * untouched — ADR-0010 §3.3 precedence is unchanged in both directions. + * + * On a COPY, always: `registerItem` hands the body to `applyProtection`, which + * mutates in place, and the callers own their `data`. + */ +function stateTenantAuthorship(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + return { ...(data as Record), _provenance: 'org' }; +} + +/** + * [#23233, #28594, #28673] A stored row's body with its package binding stated + * as `_packageId` from the row's `package_id` COLUMN, and only from it: the one + * rule every read of a stored row states its package by. A `_packageId` the + * body carries is not served: replaced by the column's package, or dropped when + * the row is bound to no package ({@link boundPackageOf}). The save door + * already strips a body's stamp ({@link stripDerivedProvenance}: the server + * states provenance, a caller never does), but a row at rest can still carry + * one, and a read that stamped the column only while the body named none + * echoed it back. The binding is read, never derived. A body that already + * states the column's binding is returned as it is; any other is a copy, so + * the caller's body (a cached row's among them) is never written. + * + * The readers: the list read ({@link storedOverlayEntries}), the list merge + * ({@link mergePackageAwareOverlay}), the draft list, the by-name active and + * draft reads, the by-name draft preview, and the registry hydration + * ({@link ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry}) with + * the items a view container row expands to. + */ +export function withRowBinding(data: unknown, packageId: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + const binding = boundPackageOf(packageId); + if ((data as { _packageId?: unknown })._packageId === binding) return data; + const bound: Record = { ...(data as Record) }; + if (binding === undefined) delete bound._packageId; + else bound._packageId = binding; + return bound; +} + +/** + * [#21639, #21638] Is this artifact a view ITEM — a view a package ships under + * its own name, `viewKind` set (the spec's ViewItem) — rather than a container + * or anything else? The save door's "a view item a package ships" and the + * package attribution of a container row both ask exactly this, positively: + * a body that is neither a container nor a view item is not a view item. + */ +function isShippedViewItem(artifact: unknown): boolean { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return false; + const viewKind = (artifact as { viewKind?: unknown }).viewKind; + return typeof viewKind === 'string' && viewKind !== ''; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { + boundPackageOf, + bundleMemberOf, + envelopePackageId, + isShippedViewItem, + mergeArtifactProtection, + perPackageLayerEnvelope, + stateTenantAuthorship, + stripDerivedProvenance, +}; +export type { HydrationOptions, UnboundOwnerRefusalState, ViewContainerWrite }; diff --git a/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts b/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts new file mode 100644 index 00000000000..b59ae52281c --- /dev/null +++ b/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the publish +// materializer, uninstall cleanup and delete-package request and response contracts. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * Result of projecting a published metadata body into its data-plane + * representation. `success:false` with an `error` is the surfaced-not-thrown + * failure contract — publishing the metadata itself always succeeds. + */ +export interface PublishMaterializeResult { + success: boolean; + inserted: number; + updated: number; + error?: string; +} + +/** + * Publish-time materializer (ADR-0086 P2). Receives the just-published body + * plus the draft's package binding and org scope. Registered per metadata type + * via {@link ObjectStackProtocolImplementation.registerPublishMaterializer}. + */ +export type PublishMaterializer = (args: { + body: unknown; + packageId: string | null; + organizationId: string | null; + actor: string; +}) => Promise; + +/** + * Uninstall-time data-plane cleanup (ADR-0086 D3, #2747). The exact mirror of + * {@link PublishMaterializer}: domain plugins own data-plane tables the + * protocol layer must not know the shape of (e.g. plugin-security's grant + * table, `sys_user_permission_set`), so they register a named + * cleanup here and {@link ObjectStackProtocolImplementation.deletePackage} + * invokes every cleanup with the uninstalled package id. Cleanups run + * best-effort — a failure is REPORTED on the uninstall response (`cleanups`), + * never thrown — but ghost grants are a security condition, so callers must + * surface a failed cleanup, not swallow it. + */ +export type UninstallCleanup = (args: { + packageId: string; + actor?: string; + /** + * What the package declared, read from the registry just before the door + * withdrew it ({@link captureUninstallDeclared}). Optional: absent when the + * registry did not hold the package or cannot answer, and a cleanup that + * does not read it behaves as before. + */ + declared?: UninstallDeclared; +}) => Promise<{ success: boolean; removed: number; error?: string }>; + +/** + * [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package + * held. A grant names its set, and the package's sets are served from the + * registry, not stored, so after the withdrawal nothing else knows them. + */ +export interface UninstallDeclared { + /** The permission-set names the package held. */ + permission?: string[]; +} + +/** + * Capture what `packageId` declared, for {@link UninstallCleanup}'s `declared` + * — the ONE capture both uninstall doors (`deletePackage` and install-local's + * `DELETE`) call, at the same point: after the uninstall is decided and just + * BEFORE `registry.uninstallPackage`, which releases the names. The withdrawal + * still precedes the cleanups (#21576); only what they are handed grows. + * + * Two sources, unioned: the names the registry says the package holds (an + * installed package's claims and item slots), and the `permission` rows of the + * package's `sys_metadata` the door already read (`stored`). The second is + * what names a writable base's sets (ADR-0070): an item authored into a base + * is stored, and the registry holds it in the bare slot with no `_packageId` + * (provenance `org`, measured), so the registry alone cannot attribute it to + * the base. + * + * `undefined` when neither source names a set — so a package that declared + * none hands its cleanups exactly the request they had before. + */ +export function captureUninstallDeclared( + registry: unknown, + packageId: string, + stored?: ReadonlyArray<{ type?: unknown; name?: unknown } | null | undefined>, +): UninstallDeclared | undefined { + const names = new Set(); + const held = (registry as { securityCatalogNamesHeldBy?: (id: string, type: 'permission') => unknown } | null | undefined) + ?.securityCatalogNamesHeldBy; + if (typeof held === 'function') { + try { + const fromRegistry = held.call(registry, packageId, 'permission'); + if (Array.isArray(fromRegistry)) { + for (const n of fromRegistry) if (typeof n === 'string' && n !== '') names.add(n); + } + } catch { /* the registry could not say; the stored rows still can */ } + } + if (stored) { + for (const row of stored) { + const type = row?.type; + if ((type === 'permission' || type === 'permissions') && typeof row?.name === 'string' && row.name !== '') names.add(row.name); + } + } + return names.size > 0 ? { permission: [...names] } : undefined; +} + +/** Per-cleanup outcome reported on the `deletePackage` response. */ +export interface UninstallCleanupOutcome { + name: string; + success: boolean; + removed: number; + error?: string; + /** + * [#12536] The user-facing refusal text the failing cleanup MARKED with + * `userMessage` (commit 79c46da90) — absent unless it declared one. This outcome is + * response DATA riding inside a `PACKAGE_DELETE_PARTIAL` 400's `details`, + * so no HTTP boundary reads a `userMessage` on its behalf; the channel has + * to exist here or the mark has nowhere to go. Read it with + * `declaredUserMessage`, never by probing the field. + */ + userMessage?: string; +} + +/** + * [#9960] The declared REQUEST shape of + * {@link ObjectStackProtocolImplementation.deletePackage} — the ONE statement + * of this verb's contract, imported by every seam that speaks it. + * + * WHY IT IS NAMED. `deletePackage` has no `packages/spec` declaration (it is + * absent from `PackageProtocol`), and until this type existed its shape was + * stated THREE times, differently, by the three modules that share the seam: + * an inline structural type on the method below; a narrower restatement on + * `PackageRoutesOptions.protocol.deletePackage` in `@objectstack/rest`, which + * named neither `organizationId` nor `keepData`; and no type at all in the + * dispatcher twin (`packages/runtime/src/domains/packages.ts`), which reached + * the verb through `(protocol as any)` and routinely sent exactly the two keys + * the REST restatement could not express. Naming the shape once, HERE at the + * producer, is what makes the other two seams compile-checked against the + * contract instead of each against its own copy. + * + * ⛔ Deliberately NOT declared in `packages/spec`: minting protocol surface for + * a verb with zero external consumers is declare-and-maintain the platform has + * not asked for. Should an external consumer ever appear, the spec declaration + * is its own card for the spec seat, not a rider on a typing convergence. + * + * [ADR-0131 D6/D12] No organization member: an uninstall is environment-wide + * by construction. The `organizationId` and `allTenants` keys that once chose + * its blast radius (#7780) are retired, and `deletePackage` refuses a request + * still carrying either (`INVALID_REQUEST`, 400). + */ +export interface DeletePackageRequest { + packageId: string; + actor?: string; + /** Remove the metadata but PRESERVE each object's physical table. */ + keepData?: boolean; +} + +/** + * [#9960] The declared RESPONSE shape of + * {@link ObjectStackProtocolImplementation.deletePackage}, stated once for the + * same reason as {@link DeletePackageRequest}. + * + * `deleted` is part of it. The REST seam's restatement omitted that member + * entirely, so a caller reading the option's type was told this verb reports + * only a COUNT of what it removed — while the producer has always returned the + * per-item list beside it. + */ +export interface DeletePackageResponse { + success: boolean; + deletedCount: number; + failedCount: number; + deleted: Array<{ type: string; name: string; state: string }>; + /** + * Per-item failures. [#12536] `userMessage` is the mark (commit 79c46da90) the item's + * own failure declared — present exactly when a producer marked its + * refusal, so a caller can tell an application refusal apart from a store + * failure on a path where no HTTP boundary can do it for them. + */ + failed: Array<{ type: string; name: string; error: string; code?: string; userMessage?: string }>; + cleanups: UninstallCleanupOutcome[]; +} diff --git a/packages/metadata-protocol/src/protocol-publish-backfill-request.ts b/packages/metadata-protocol/src/protocol-publish-backfill-request.ts new file mode 100644 index 00000000000..941108a9e82 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-publish-backfill-request.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the publish- +// backfill entry, receipt, page size, refusal and request reader. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import type { MetadataIssueEntry } from './protocol-metadata-issues.js'; + +/** + * [#24253] One field a `publishPackageDrafts` backfill will write: the entry as + * the request named it, plus the literal its draft declares. Built only by + * `planPublishBackfill`, so every value here was judged before the publish. + */ +export interface PublishBackfillEntry { + object: string; + field: string; + value: unknown; +} + +/** + * [#24253] The `backfillApplied` receipt of `publishPackageDrafts`, declared as + * `PublishPackageDraftsResponseSchema.backfillApplied` in `@objectstack/spec/api`. + * `updated` counts the rows this publish wrote, so a row that already held a + * value (a database column DEFAULT fills one on the SQL driver) is not counted. + */ +export type PublishBackfillReceipt = { + success: boolean; + fields: Array<{ object: string; field: string; updated: number; error?: string; code?: string }>; +}; + +/** [#24253] Rows read and written per round of one field's backfill. */ +export const PUBLISH_BACKFILL_PAGE_SIZE = 200; + +/** + * [#24253] The 400 a `publishPackageDrafts` backfill request is refused with: + * every entry that cannot be honoured, by path, in one answer, before anything + * is promoted or written. The headline names the first; `issues[]` names all. + */ +export function publishBackfillRefusal( + packageId: string, + issues: MetadataIssueEntry[], +): Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] } { + const first = issues[0]!; + const err = new Error( + `Nothing was published: the backfill of package '${packageId}' cannot be honoured ` + + `(${issues.length} ${issues.length === 1 ? 'problem' : 'problems'}; first at ${first.path}: ${first.message}) ` + + 'Fix or drop the named entries and publish again.', + ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = issues; + return err; +} + +/** + * [#24253] The shape half of the backfill judgement: an array of + * `{ object, field }` pairs, each named once, and the caller's context to write + * as. Read before any server state, so a malformed request is refused the same + * way whatever the package holds. Absent is nothing asked; so is `[]`. + */ +function readPublishBackfillRequest( + packageId: string, + backfill: unknown, + context: unknown, +): Array<{ object: string; field: string }> { + if (backfill === undefined) return []; + const issues: MetadataIssueEntry[] = []; + if (!Array.isArray(backfill)) { + throw publishBackfillRefusal(packageId, [{ + path: 'backfill', + message: 'Must be an array of { object, field } pairs naming the fields this publish adds.', + code: 'invalid_type', + }]); + } + const entries: Array<{ object: string; field: string }> = []; + const seen = new Set(); + backfill.forEach((entry: unknown, i: number) => { + const path = `backfill.${i}`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + issues.push({ path, message: 'Must be an object { object, field }.', code: 'invalid_type' }); + return; + } + const extra = Object.keys(entry).filter((k) => k !== 'object' && k !== 'field'); + if (extra.length > 0) { + issues.push({ + path, + message: `Unrecognized ${extra.length === 1 ? 'key' : 'keys'} ${extra.map((k) => `'${k}'`).join(', ')}: ` + + 'an entry names an object and a field, and the value written is the field\'s own defaultValue.', + code: 'unrecognized_keys', + }); + return; + } + const { object, field } = entry as { object?: unknown; field?: unknown }; + if (typeof object !== 'string' || object.length === 0 || typeof field !== 'string' || field.length === 0) { + issues.push({ path, message: 'Both `object` and `field` must be non-empty strings.', code: 'invalid_type' }); + return; + } + const key = `${object}.${field}`; + if (seen.has(key)) { + issues.push({ path, message: `'${key}' is named more than once.`, code: 'custom' }); + return; + } + seen.add(key); + entries.push({ object, field }); + }); + if (entries.length > 0 && (!context || typeof context !== 'object')) { + issues.push({ + path: 'backfill', + message: 'A backfill writes as the caller, and no caller context reached this publish: ' + + 'pass the caller\'s execution context as `context`. It is never written as the system.', + code: 'custom', + }); + } + if (issues.length > 0) throw publishBackfillRefusal(packageId, issues); + return entries; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { readPublishBackfillRequest }; diff --git a/packages/metadata-protocol/src/protocol-reference-hits.ts b/packages/metadata-protocol/src/protocol-reference-hits.ts new file mode 100644 index 00000000000..8f45204d422 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-reference-hits.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the +// reference-site walk behind `findReferencesToMeta`. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * Phase 3a-references: how a reference is FOUND in a stored document, given the + * derived site index in `./reference-sites.ts`. + * + * The registry that used to sit here was a hand-curated table of `fromType` + + * fully-qualified dotted paths. It rotted — 34 of its 40 paths named properties + * the source type's schema does not declare, leaving five of its seven target + * keys answering `{ references: [] }` unconditionally (#9190; the measurements + * are in `reference-sites.ts`'s header, which is the one place they live). + * + * ⛔ Do not reintroduce a path table. Paths were the wrong unit: `AppSchema`'s + * navigation is recursive, so an exhaustive path list is unbounded, and it goes + * stale the moment a wrapper key moves. The derived index records the PROPERTY + * that carries a name; this walk finds it wherever the document actually puts + * it, and reports the path it was found at. + */ + +/** + * Every name a reference-bearing property value may be carrying, tolerant of + * the four shapes a name can arrive in. + * + * Deliberately shape-TOLERANT at read time while the derivation is shape-STRICT + * at build time: the derivation decides whether a property means "a reference" + * (and rejects `enum`-constrained lookalikes such as a chart axis `position`), + * while this function only has to read a value whose meaning is already + * settled. Record KEYS are included because `z.record(name, …)` is how the + * platform spells a name-keyed collection — `PermissionSetSchema.objects` is + * exactly that, and the old table's `objects[].name` could not express it. + */ +function referencedNamesAt(value: unknown): Array<{ name: string; suffix: string }> { + if (typeof value === 'string') { + return value.length > 0 ? [{ name: value, suffix: '' }] : []; + } + if (Array.isArray(value)) { + const out: Array<{ name: string; suffix: string }> = []; + for (const element of value) { + if (typeof element === 'string' && element.length > 0) out.push({ name: element, suffix: '[]' }); + else if (element && typeof element === 'object') { + const named = (element as Record).name; + if (typeof named === 'string' && named.length > 0) out.push({ name: named, suffix: '[].name' }); + } + } + return out; + } + if (value && typeof value === 'object') { + const out: Array<{ name: string; suffix: string }> = []; + const record = value as Record; + for (const key of Object.keys(record)) { + if (key.length > 0) out.push({ name: key, suffix: '{key}' }); + } + const named = record.name; + if (typeof named === 'string' && named.length > 0) out.push({ name: named, suffix: '.name' }); + return out; + } + return []; +} + +/** + * Walk a stored metadata document and report every place it names + * `targetName` through one of `properties`. + * + * `properties` is the derived property → target binding for THIS source type, + * so the walk never guesses; it only looks where the schema said a name lives. + * Paths are reported with array indices and record keys elided (`a.b[].c`, + * `a{}.b`), matching the grammar the previous table used, so the value the + * "Used by" panel renders keeps its established shape. + */ +function collectReferenceHits( + item: unknown, + properties: ReadonlyMap, + targetName: string, +): Array<{ path: string; property: string }> { + const hits: Array<{ path: string; property: string }> = []; + const seenNodes = new Set(); + + const walk = (node: unknown, trail: string, depth: number): void => { + if (!node || typeof node !== 'object' || depth > 16) return; + // Cycle guard. Stored documents are JSON, but a runtime-assembled item + // can still be self-referential, and a scan that hangs is a worse + // answer than a short one. + if (seenNodes.has(node)) return; + seenNodes.add(node); + + if (Array.isArray(node)) { + for (const element of node) walk(element, `${trail}[]`, depth + 1); + return; + } + for (const [key, value] of Object.entries(node as Record)) { + const here = trail ? `${trail}.${key}` : key; + if (properties.has(key)) { + for (const hit of referencedNamesAt(value)) { + if (hit.name === targetName) hits.push({ path: `${here}${hit.suffix}`, property: key }); + } + } + walk(value, here, depth + 1); + } + }; + + walk(item, '', 0); + return hits; +} + +/** + * Every place a stored document names `targetName` as the HEAD of a composite + * record key, through a composite-key site of the derived index + * (`REFERENCE_SITES.compositeKeyByTarget`; the one site today is a permission + * set's `fields`, keyed `.`). + * + * A separate walk, not a branch inside {@link collectReferenceHits}: that walk + * matches a WHOLE value and serves every other site, so it stays as it is. This + * one reads only the record each site names, at the document ROOT where the + * site's premise is derived (`compositeKeySiteHolds`, `./reference-sites.ts`), + * and only its keys. + * + * A key names the target when the text before its FIRST separator equals the + * target's name — the split the security plugin makes for field rules + * (`key.startsWith(object + '.')`), exact because no object name contains a `.`. + * A key with no separator, or one at index 0 (`.full_name`), names no object. + * The field part is never read, so a key whose FIELD part spells the target + * (`invoice.customer`) is not a reference to it. One hit per site: the reported + * path (`fields{key}`) does not carry the key, so a second matching key is the + * same row. + */ +function collectCompositeKeyHeadHits( + item: unknown, + separators: ReadonlyMap, + targetName: string, +): Array<{ path: string; property: string }> { + const hits: Array<{ path: string; property: string }> = []; + if (!item || typeof item !== 'object' || Array.isArray(item)) return hits; + for (const [property, separator] of separators) { + const record = (item as Record)[property]; + if (!record || typeof record !== 'object' || Array.isArray(record)) continue; + for (const key of Object.keys(record)) { + const at = key.indexOf(separator); + if (at > 0 && key.slice(0, at) === targetName) { + hits.push({ path: `${property}{key}`, property }); + break; + } + } + } + return hits; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { collectCompositeKeyHeadHits, collectReferenceHits }; diff --git a/packages/metadata-protocol/src/protocol-stored-overlay-types.ts b/packages/metadata-protocol/src/protocol-stored-overlay-types.ts new file mode 100644 index 00000000000..a2f47c5e89e --- /dev/null +++ b/packages/metadata-protocol/src/protocol-stored-overlay-types.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the non- +// canonical stored type predicate, the stored overlay entry and the rowless expanded view. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { type ServedOverrideRow } from './served-override.js'; +import { canonicalMetaType } from './protocol-meta-type-schema.js'; + +/** + * [#8908] Is this AT-REST `type` a spelling the batch publish must refuse? + * + * ## The class, stated as a predicate rather than a list of four + * + * `publishPackageDrafts` reads `sys_metadata` rows, so its input is a stored + * `type` — not a URL segment, which is why #7894's boundary fold + * ({@link canonicalizeMetaRequestType}) never reaches it. `promoteDraftForPublish` + * folds the stored spelling through `PLURAL_TO_SINGULAR`, the MANIFEST-collection + * map, and that map legitimately omits types which are not stack collections. + * For those, the fold is a NO-OP: the lookup key equals the stored spelling, the + * draft resolves, and the publish mints an ACTIVE row in the very namespace + * `PUT /meta/field/…` answers 403 NOT_OVERRIDABLE for — every registry read and + * every compliance query on `field` missing an item the platform just reported + * as published. + * + * The two limbs below are that sentence, mechanically: + * + * 1. `canonicalMetaType(type) !== type` — the platform's own URL/registry map + * says this spelling names a DIFFERENT type than the one it is stored as, so + * the row lives in a second namespace for an item that has a canonical home. + * A canonical type fails this limb by construction (no canonical spelling + * folds elsewhere — 33 of 33, measured), and so does a plugin-registered or + * unrecognised kind (identity fold), which is what keeps the gate off + * everything that is not this defect. + * 2. the MANIFEST fold leaves it unchanged — i.e. `promoteDraftForPublish` + * would look the row up under its stored spelling and FIND it. + * + * ⛔ Limb 2 is not decoration and must not be dropped for "one simpler rule". + * A manifest-PRESENT plural (`objects`) is already fail-closed: the promote + * addresses the row by the folded singular, `whereFor` emits that spelling with + * no at-rest fallback, and the batch aborts on `NO_DRAFT` — pinned, with its + * reasoning, in `protocol.publish-side-effects-canonical-type.test.ts`. Widening + * this gate over that class would change a wire-visible `failed[].code` for rows + * that are NOT this defect. #8908's ruling names one class; this predicate is it. + * + * ## Why a predicate and not the four names the card lists + * + * The card names `fields`, `seeds`, `external_catalogs`, `translations`. Measured + * against the real maps, the class is SIX spellings: those four plus + * `externalCatalogs` (the camelCase limb of the same type) and `email_templates` + * (a registry type whose snake_case plural the manifest map also omits, while + * carrying `emailTemplates`). A hand-written list would have shipped with two + * members missing and no way to notice; this derivation cannot drift from the + * maps it reads, and a newly declared type that never reaches the manifest map + * is covered on the day it is declared. + */ +function isNonCanonicalStoredType(type: string): boolean { + return canonicalMetaType(type) !== type && (PLURAL_TO_SINGULAR[type] ?? type) === type; +} + +/** + * [#21442] A stored `sys_metadata` row as the list read parses it: its own + * name, its body (stored-row conversions replayed), and the package and + * organization it is bound to (`organizationId: null` for an environment-wide + * row). [#21804] `type` is the spelling the row is stored under (its own + * `type` column), which the served-row resolution reads. + */ +export interface StoredOverlayEntry { + name: string; + data: any; + packageId: string | undefined; + organizationId: string | null; + type: string; +} + +/** + * [#28412] A parsed stored row as {@link ObjectStackProtocolImplementation.declinesStoredRow} + * judges it: its stored type spelling, its `package_id` column (the entry's + * `packageId`, never the body's `_packageId`) and its parsed body. + */ +function storedEntryRow(entry: Pick): ServedOverrideRow { + return { type: entry.type, package_id: entry.packageId ?? null, metadata: entry.data }; +} + +/** + * [#21442] A row-less view name a stored view container expands: the item the + * list read serves under the name, and the stored container row it derives + * from. + */ +export interface RowlessExpandedView { + item: Record; + container: StoredOverlayEntry; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { isNonCanonicalStoredType, storedEntryRow }; diff --git a/packages/metadata-protocol/src/protocol-version-tokens.ts b/packages/metadata-protocol/src/protocol-version-tokens.ts new file mode 100644 index 00000000000..b16c7540df5 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-version-tokens.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the ETag +// hash, the two version-token error classes, and the canonical version and audit instants. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * Simple hash function for ETag generation (browser-compatible) + * Uses a basic hash algorithm instead of crypto.createHash + */ +function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); +} + +/** + * Thrown by `updateData` / `deleteData` when the caller supplies an + * `expectedVersion` that does not match the current record's `updated_at`. + * + * The HTTP layer maps this to `409 Conflict` with code `CONCURRENT_UPDATE`, + * and includes both the current server-side version and the current record + * payload so the client can render an informed conflict-resolution UI + * ("Reload latest" vs. "Overwrite anyway"). The record is the row as the + * caller reads it, and a row the caller cannot read is never a conflict: it + * answers not-found instead (#23586, see `assertVersionOf`). + * + * NOTE: This is an *application-level* compare-and-set — not an atomic + * storage-layer CAS. There is a small TOCTOU window between the version + * check and the subsequent write. For the conflict frequency this targets + * (different users seconds-to-minutes apart in B2B record editing) this + * is more than adequate; a future revision can push the check into the + * driver's UPDATE statement (`WHERE id=? AND updated_at=?`) for true + * atomicity. + */ +export class ConcurrentUpdateError extends Error { + readonly code = 'CONCURRENT_UPDATE'; + readonly status = 409; + readonly currentVersion: string | null; + readonly currentRecord: unknown; + constructor(opts: { currentVersion: string | null; currentRecord: unknown; message?: string }) { + super(opts.message ?? 'Record was modified by another user'); + this.name = 'ConcurrentUpdateError'; + this.currentVersion = opts.currentVersion; + this.currentRecord = opts.currentRecord; + } +} + +/** + * [#13576] Thrown when a caller-supplied `expectedVersion` — the `If-Match` + * header or the body/query `expectedVersion` field — is a syntactically legal + * but semantically empty RFC-7232 entity-tag: the quoted-empty token `""`. + * + * ## Why this is refused rather than silently treated as "no token" (the + * pre-#13576 behaviour) or silently treated as a conflict (409) + * + * `""` is valid `entity-tag` grammar (RFC 7232 §2.3: `weak? DQUOTE *etagc + * DQUOTE`, and `*etagc` — zero or more — permits an EMPTY opaque-tag). But an + * empty tag can never equal any stored `updated_at`, quoted or not: it is not + * "the version I read", it is nothing. A client that sends it is asking for a + * guarded write and could not possibly have a real version in hand — RFC + * legality does not make it a meaningful concurrency token, and the platform + * makes an explicit contract choice to treat the shape itself as a client + * defect. Before this it fell through {@link normaliseVersionToken}'s + * quote-strip into the same falsy `''` that "caller sent nothing" produces, + * so the OPPOSITE of what `If-Match` requests happened: the guard was + * SKIPPED rather than evaluated, silently, on the one token shape that opts + * OUT of the check instead of failing it (unlike a garbage-but-nonempty token + * such as `v2`, which still normalises to a real, comparable token and fails + * toward `409 CONCURRENT_UPDATE` — the safe direction for a concurrency + * primitive, and unchanged by this fix). + * + * A `409` here (the alternative the maintainer ruling rejected, decision #20 + * ①, 2026-08-31) would have been safer than the pre-#13576 behaviour but + * would still have collapsed two different facts into one answer: "you lost a + * race" (409, actionable by reloading and retrying) and "you sent something + * that can never carry a version" (this — a client-side bug, not a race). A + * `400` keeps that distinction legible, which is the entire reason option 3 + * was chosen over option 2. + * + * `VALIDATION_FAILED`/400 is this package's own house code for a + * caller-request defect the engine never has to touch (mirrors + * {@link rowRequiredIdError}) — already registered in the ADR-0112 + * error-code ledger under `@objectstack/metadata-protocol`, so this needs no + * new ledger entry. + */ +export class MalformedVersionTokenError extends Error { + readonly code = 'VALIDATION_FAILED'; + readonly status = 400; + readonly fields: never[] = []; + constructor() { + super( + 'expectedVersion (If-Match) is the empty entity-tag `""`. An empty version ' + + 'token can never match any stored version, so this is almost certainly a ' + + 'client defect rather than a real concurrency check — send the real version ' + + 'token you read (e.g. the record\'s `updated_at`), or omit If-Match / ' + + 'expectedVersion entirely to perform an unguarded write.', + ); + this.name = 'MalformedVersionTokenError'; + } +} + +/** + * An ISO-8601 date-time that denotes an ABSOLUTE instant — it carries an + * explicit `Z` or a numeric `±HH:mm` offset, so reading it never consults the + * process timezone. + * + * Deliberately narrower than a bare `Date.parse`, which falls back to + * implementation-specific heuristics outside this shape and reads a zone-LESS + * date-time as *local* time — the one thing an OCC verdict must never depend on. + * A string this pattern rejects is not reinterpreted: it stays an opaque token + * and is compared verbatim, exactly as before. + */ +const ABSOLUTE_ISO_INSTANT = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; + +/** Largest magnitude a JS time value may hold; `toISOString()` throws beyond it. */ +const MAX_TIME_VALUE = 8.64e15; + +/** + * The canonical absolute-instant spelling of a version token, or null when the + * token does not denote an instant. + * + * The input domain is the set of things a DRIVER puts in `updated_at`, measured + * rather than listed from memory (#13382): + * + * - **JS `Date`** — `driver-sql` on Postgres and MySQL (`timestamptz` / + * `DATETIME(3)` are instants, and the driver materialises them as `Date` on + * purpose; `SqlDriver.withPostgresCalendarDayAsText` says so in as many + * words) and `driver-mongodb` (it stamps `new Date()`, and BSON round-trips + * it). Canonical form: `toISOString()`. + * - **`string`, already canonical ISO-8601 UTC with milliseconds** — + * `driver-sql` on SQLite, its `driver-turso` / `driver-sqlite-wasm` siblings, + * and `driver-memory`. Canonical form: itself, re-derived through `Date`. + * - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated SQLite + * column. Reachable: the driver's legacy datetime repair is keyed on declared + * `Field.datetime` columns, and the engine-injected audit columns are not in + * that set, so such a value passes through unrepaired. Canonical form: + * `new Date(ms).toISOString()`. + * - **`null` / `undefined`** — timestamps disabled, or no such column. No + * check runs at all; handled by the caller, not here. + * - **anything else** a host stamps into the column — opaque, and compared + * verbatim rather than guessed at. + * + * Milliseconds are the resolution, because that is what a JS `Date` — and so + * every driver's materialised instant — can carry. A token with more fractional + * digits (a Postgres microsecond rendering, say) truncates to the same + * millisecond as the record it is compared against, which is the point: two + * spellings of one instant must not conflict. + */ +export function canonicalVersionInstant(value: unknown, token: string): string | null { + let ms: number; + if (value instanceof Date) { + ms = value.getTime(); + } else if (typeof value === 'number') { + ms = value; + } else if (ABSOLUTE_ISO_INSTANT.test(token)) { + ms = Date.parse(token); + } else { + return null; + } + if (!Number.isFinite(ms) || Math.abs(ms) > MAX_TIME_VALUE) return null; + return new Date(ms).toISOString(); +} + +/** The verbatim spelling the ADR-0067 timeline compared before #13995 - `String(v ?? '')`. */ +function auditInstantToken(value: unknown): string { + return value === null || value === undefined ? '' : String(value); +} + +/** + * Epoch milliseconds for a DRIVER-stamped audit column, or null when the value + * does not denote an instant. + * + * The input domain is {@link canonicalVersionInstant}'s, and it is delegated to + * rather than re-spelled: that domain (`Date` / epoch `number` / canonical ISO + * text / opaque) was measured off the drivers for #13382 and is the same set a + * `created_at` arrives in. Its canonical answer round-trips through `Date.parse` + * exactly, and it has already bounded the value to a finite, `toISOString`-able + * time, so the number below is always finite. + */ +function auditInstantMs(value: unknown): number | null { + if (value === null || value === undefined) return null; + const canonical = canonicalVersionInstant(value, auditInstantToken(value).trim()); + return canonical === null ? null : Date.parse(canonical); +} + +/** + * Compare two engine-stamped audit instants, OLDEST-FIRST (`< 0` when `a` is + * older than `b`) - the shape both a `sort` comparator and a "strictly newer + * than" filter need. + * + * ## Why this exists (#13995) + * + * `created_at` is an engine-injected audit column: it is not in `datetimeFields`, + * and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`, so + * the live dialects hand it out of the record read door as a JS `Date` and the + * SQLite family as canonical ISO-Z text (pinned by driver-sql's + * `sql-driver-13567-audit-stamp-materialisation.test.ts`). `String(aDate)` is + * `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"`, whose LEADING + * token is the weekday NAME - so a lexicographic compare over those strings + * orders `Fri < Mon < Sat < Sun < Thu < Tue < Wed`, which has nothing to do with + * chronology. It is stable across the whole set, so the failure is systematic + * rather than intermittent: there is never an "it worked once" to warn anyone. + * Both ADR-0067 commit-timeline consumers compared exactly that way, and one of + * them - {@link ObjectStackProtocolImplementation.rollbackToPackageCommit} - + * chooses the set of commits to UNDO with it. + * + * ## Why a sibling of {@link canonicalVersionInstant} rather than a reuse of + * {@link versionTokensAgree} + * + * The canonicalisation IS reused (see {@link auditInstantMs}). What is not is + * `versionTokensAgree`: it answers EQUALITY between two client-facing version + * TOKENS, and ordering needs `<`/`>`, not `===`. It also takes the pre-tidied + * {@link NormalisedVersion} pair an HTTP `If-Match` header produces - RFC-7232 + * quote stripping, the "no token supplied" opt-out - none of which applies to a + * column a driver stamped and no client ever sends. + * + * The opaque limb is deliberately the one `versionTokensAgree` documents: when + * either side does not denote an instant, the two are compared VERBATIM, exactly + * as this seam did before. So the only verdicts that change are pairs that denote + * instants - the defect - and nothing a host stamps into the column starts + * ordering differently. This is not a tolerant fallback over a mis-spelled key + * (#13973's standing prohibition): no alternative spelling is accepted anywhere + * here, and an unparseable value is not repaired, only left where it was. + */ +function compareAuditInstants(a: unknown, b: unknown): number { + const aMs = auditInstantMs(a); + const bMs = auditInstantMs(b); + if (aMs !== null && bMs !== null) { + return aMs < bMs ? -1 : aMs > bMs ? 1 : 0; + } + const aToken = auditInstantToken(a); + const bToken = auditInstantToken(b); + return aToken < bToken ? -1 : aToken > bToken ? 1 : 0; +} + +/** + * Canonicalise the ONE driver materialisation {@link listCommits} was + * measured to produce for `sys_metadata_commit.created_at` — a valid JS + * `Date` — into the ISO-8601 string the return type declares (`createdAt?: + * string`). Every other shape, INCLUDING an Invalid `Date`, is returned + * UNTOUCHED. + * + * [#14038] `created_at` is an engine-injected audit column: it is not in + * `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its + * `if (this.isSqlite)` arm (pinned live in driver-sql's + * `sql-driver-13567-audit-stamp-materialisation.test.ts`), so Postgres and + * MySQL hand it out of the record read door as a JS `Date` while the + * mapping below assigned it straight through as `r.created_at` — an + * unchecked value from an `any[]` row, never a measurement against the + * declared `string` return type. + * + * ⚠️ Deliberately NOT the `canonicalIsoInstant` spelling next door in + * `sys-metadata-repository.ts` / `database-loader.ts` — which is, since + * #16422, the ONLY spelling at #14037's sibling sites. That difference used + * to be exactly one input shape — the Invalid + * `Date` on which that spelling raised `RangeError: Invalid time value`, + * measured reachable on BOTH live dialects (a MySQL zero datetime; any + * Postgres year in 275760..294276). #14078 has since RULED it (option B, + * 2026-09-02): that arm is now total and answers `undefined` for the shape. + * + * ⛔ They are still not ONE spelling, and **#16422 ruled that this copy is the + * one that stays**. That card collapsed the family's other four call sites — + * `rowToEvent` in `sys-metadata-repository.ts` and the three adapter + * boundaries in `database-loader.ts` — into `canonicalIsoInstant` and deleted + * both sibling definitions of this spelling. This site was held out, for the + * reason the last paragraph below states: `listCommits` promises its callers + * the RAW value back for a non-`Date`, and `canonicalIsoInstant` rewrites the + * whole domain. Measured on the seven inputs that distinguish the two + * helpers, swapping it in here moves three: an Invalid `Date` would be ERASED + * from the response (`undefined` — the one answer [ADR-0053 D-F3] refuses, + * because it silently drops a value that is on disk), and a `number` and an + * opaque object would reach {@link compareAuditInstants} as `String(value)` + * rather than verbatim, reordering rows this seam deliberately leaves alone. + * + * ⇒ The family is now two DELIBERATE helpers, not one pending merge: the + * shared domain rewrite at the sites whose declared field is a + * `z.string().datetime()` and whose caller carries a terminal value, and this + * narrow one-shape conversion at the site whose declared contract is + * pass-through. ⛔ Do not collapse it without superseding that ruling. + * + * ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no + * consumer to accept an off-spec shape; it converts the one measured + * producer materialisation at the producer. The + * `!Number.isNaN(value.getTime())` guard is the same one #14037 used for + * its two sibling sites, not a new spelling. + * + * ⛔ Not exported and not merged into {@link canonicalVersionInstant} above: + * that helper answers a different question (does this token denote AN + * instant at all, returning `null` when it does not) and callers of + * `listCommits` are promised the RAW value back untouched when it is not a + * valid `Date` — an absent/opaque column must still reach `sort`'s fallback + * branch and any in-process reader exactly as before. Consolidating the + * family's near-identical copies was expected to be #14078's call; that + * ruling covered only the five arms that threw, and #16422 then ruled this + * promise the reason to keep this copy rather than the obstacle to removing + * it. §D of `protocol-14038-list-commits-created-at-iso.test.ts` is the pin. + */ +function isoFromValidDate(value: unknown): unknown { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString(); + return value; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { compareAuditInstants, isoFromValidDate, simpleHash }; diff --git a/packages/metadata-protocol/src/protocol-view-normalization.ts b/packages/metadata-protocol/src/protocol-view-normalization.ts new file mode 100644 index 00000000000..117438c738a --- /dev/null +++ b/packages/metadata-protocol/src/protocol-view-normalization.ts @@ -0,0 +1,432 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: view +// normalization at save: the operator and form-section grafts, the storable view body, and the +// declared page-type default. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { z } from 'zod'; + +/** + * Guarantee a `view` body carries a top-level `name`. + * + * {@link ObjectStackProtocolImplementation.getMetaItems} only surfaces a + * sys_metadata overlay row when its parsed body has a top-level `name` (objects + * and dashboards include one; some view producers — notably loose `{ list }` + * fragments — do not, so the view is silently dropped from the object's view + * list and never appears as a tab). We stamp the save name here, at the single + * write chokepoint, without otherwise reshaping the document. + * + * Deliberately does NOT convert shape: both the `defineView` container form + * (`{ list, listViews, … }`) and the `{ name, object, viewKind, config }` + * record form are valid and the console consumes both — reshaping a container + * into a record risks producing an invalid record (e.g. a non-`.` + * name). Structural validity is enforced separately by the view metadata schema + * during the spec-validation step. No-op for non-view types and bodies that + * already carry a `name`. [#21412, #21470] A view's authored `name` reaches + * this function only when it equals `saveName`: `saveMetaItem` refuses a + * disagreeing one first, through `savedItemNameRefusal`, so keeping the + * authored `name` can no longer file a view under a second key. + * + * When `baseline` is provided (the registry entry this overlay will shadow), + * missing identity fields — `viewKind`, `object`, `label` — are inherited onto + * non-container bodies. A runtime personalization PUT (console column sort, + * inline edit, …) sends only the raw view config; persisting it verbatim makes + * the overlay replace the flattened package entry minus its identity, and the + * view silently drops out of every consumer that filters on + * `viewKind`/`object` (e.g. the switcher endpoint). See #2555. Container + * bodies are left untouched — `expandViewContainer` derives identity itself. + */ +export function normalizeViewMetadata(type: string, item: unknown, saveName: string, baseline?: unknown): unknown { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (singular !== 'view') return item; + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const it = item as Record; + const patch = viewIdentityPatch(it, baseline); + if (it.name && !patch) return it; + return { ...it, ...(it.name ? undefined : { name: saveName }), ...patch }; +} + +/** + * Persist the operator spellings the spec's own schema normalized, and nothing + * else. objectui#2945. + * + * `ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`, + * so a stored `notEquals` / `gt` / `isNull` is folded to its canonical form + * during validation — and then the result was thrown away, because `saveMeta` + * persists the authored body verbatim (deliberately: `parsed.data` strips the + * Studio-only auxiliary fields that ride along with an overlay). The alias table + * `VIEW_FILTER_OPERATOR_ALIASES` therefore keeps acquiring *new* rows with every + * save, which is why it can never be retired: there is no point at which the + * last alias row is behind you. + * + * This grafts the normalization back on without giving up the verbatim body. + * It walks the authored value and the parsed value in lockstep **by structure** + * and copies across exactly one thing: an `operator` whose parsed value differs + * from the authored one. No key list to maintain — every filter site the schema + * knows about is covered, including ones added later — and nothing is added, + * removed, reordered or defaulted, so an auxiliary field cannot be lost the way + * a wholesale `parsed.data` swap would lose it. + * + * Returns the input itself when nothing changed, so the common case allocates + * nothing. + */ +export function graftNormalizedOperators(authored: unknown, parsed: unknown): unknown { + if (Array.isArray(authored)) { + if (!Array.isArray(parsed)) return authored; + let changed = false; + const out = authored.map((entry, i) => { + const next = graftNormalizedOperators(entry, parsed[i]); + if (next !== entry) changed = true; + return next; + }); + return changed ? out : authored; + } + + if (!authored || typeof authored !== 'object') return authored; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return authored; + + const a = authored as Record; + const p = parsed as Record; + let patch: Record | undefined; + + for (const [key, value] of Object.entries(a)) { + // The one value this function is allowed to rewrite. Guarded on both + // sides being strings so a `$`-token condition object — a different + // operator vocabulary entirely — cannot be reshaped by accident. + if (key === 'operator' && typeof value === 'string' && typeof p[key] === 'string') { + if (p[key] !== value) (patch ??= {})[key] = p[key]; + continue; + } + const next = graftNormalizedOperators(value, p[key]); + if (next !== value) (patch ??= {})[key] = next; + } + + return patch ? { ...a, ...patch } : authored; +} + +/** + * Persist the `groups` → `sections` fold the spec's own schema performed, and + * nothing else. #7134, the save-path half of #6926 / PR #7128. + * + * `FormViewSchema` carries `foldFormGroupsIntoSections` as a `.overwrite()` + * check, so a form authored with the legacy `groups` alias parses to one + * carrying canonical `sections` — and then the result was thrown away, because + * `saveMeta` persists the authored body verbatim (deliberately: `parsed.data` + * strips the Studio-only auxiliary fields that ride along with an overlay). A + * Studio-saved public form therefore reached every `sections`-reading consumer + * still spelled `groups`, and `packages/rest`'s `/forms/:slug` routes degraded + * on exactly that: an empty published field schema, an empty `allowedFields` + * whitelist on submit (#6920), and a 403 for every field on the anonymous + * lookup picker (a route since retired, #21180). Same shape of gap as + * {@link graftNormalizedOperators}, and the + * same consequence — while saves keep minting the authored spelling, the alias + * can never be retired and the objectui-side folds cannot be removed. + * + * ## Why this is a SIBLING of {@link graftNormalizedOperators}, not a parameter + * + * That function walks authored and parsed in lockstep by structure and copies + * across a changed SCALAR at a key both sides carry. `groups` → `sections` is a + * KEY MOVE — one key removed, another added — which its per-key loop cannot + * express: it iterates the AUTHORED keys and only ever patches a key already + * there, so it can neither drop `groups` nor introduce `sections`. Measured, not + * assumed (#7134). + * + * ## How the fold is DETECTED, rather than guessed + * + * At each structural position the fold is taken to have happened iff the author + * wrote `groups`, the parse did NOT keep it, and the parse produced `sections` + * in its place. That is the exact post-condition of + * `foldFormGroupsIntoSections`, so no list of "places a form can live" is + * maintained here: the top-level flattened overlay, `config` on a `ViewItem`, + * and `form` / `formViews.*` on a container are all covered by the same walk, + * and a form slot added later is covered without an edit. A `groups` key the + * schema keeps (a different vocabulary — `app.zod`'s nav groups, a passthrough + * record) fails the middle test and is left alone. + * + * ## What is moved is the AUTHORED array, not the parsed one + * + * `parsed.data`'s sections carry schema defaults (`collapsible`, `collapsed`, + * `columns`); persisting those would be the wholesale swap this whole design + * avoids. The authored array moves verbatim — the producer's fold is + * `FormSectionSchema` → `FormSectionSchema` with no sub-key rewriting, so the + * moved value is already the canonical one. When the author wrote BOTH keys the + * producer keeps `sections` and drops `groups`, empty array included; so does + * this. + * + * Returns the input itself when nothing changed, so the common case allocates + * nothing. + */ +export function graftFoldedFormSections(authored: unknown, parsed: unknown): unknown { + if (Array.isArray(authored)) { + if (!Array.isArray(parsed)) return authored; + let changed = false; + const out = authored.map((entry, i) => { + const next = graftFoldedFormSections(entry, parsed[i]); + if (next !== entry) changed = true; + return next; + }); + return changed ? out : authored; + } + + if (!authored || typeof authored !== 'object') return authored; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return authored; + + const a = authored as Record; + const p = parsed as Record; + + // The post-condition of `foldFormGroupsIntoSections`, read off this position. + const folded = a.groups !== undefined && p.groups === undefined && p.sections !== undefined; + + let patch: Record | undefined; + for (const [key, value] of Object.entries(a)) { + // When the fold fired, the parsed node structurally corresponding to the + // authored `groups` is `sections` — descending against `p.groups` there + // would silently stop walking at the one key this function is about. + const counterpart = folded && key === 'groups' ? p.sections : p[key]; + const next = graftFoldedFormSections(value, counterpart); + if (next !== value) (patch ??= {})[key] = next; + } + + if (!folded) return patch ? { ...a, ...patch } : authored; + + const { groups, ...rest } = patch ? { ...a, ...patch } : a; + // `sections` wins when the author wrote it — including as an empty array, + // which is what the producer's fold does and therefore what already renders. + return rest.sections !== undefined ? rest : { ...rest, sections: groups }; +} + +/** + * How many verify-and-restore rounds {@link projectStorableViewBody} may take. + * One round per level of key-moved nesting is what convergence costs: a + * `groups` → `sections` fold whose sections carried a moved key of their own + * (the `visibleOn` → `visibleWhen` fold, until that alias retired at 18) is + * two levels. A `view` body is a bounded document, so a + * body still diverging after this many rounds is not converging at all. + */ +const STORABLE_VIEW_MAX_ROUNDS = 8; + +/** A JSON object — not an array, not `null`. */ +export function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Equality as the store sees it: `JSON.stringify` semantics, so a key whose + * value is `undefined` is the same as an absent key, and key ORDER does not + * count (the list overlay's `type` default is re-applied in declaration + * position, so a re-parse can reorder keys it did not change). + */ +function storableEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((entry, i) => storableEqual(entry, b[i])); + } + if (isPlainRecord(a) && isPlainRecord(b)) { + const keysA = Object.keys(a).filter((k) => a[k] !== undefined); + const keysB = Object.keys(b).filter((k) => b[k] !== undefined); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => storableEqual(a[k], b[k])); + } + return false; +} + +/** + * The one authored key, among those the parse did not keep at this position, + * whose value has the shape of `value` — the key a moved key was moved FROM. + * `undefined` when there is none, or more than one. + */ +function soleMovedFrom(dropped: readonly string[], authored: Record, value: unknown): string | undefined { + const sameShape = (candidate: unknown): boolean => { + if (Array.isArray(value)) return Array.isArray(candidate) && candidate.length === value.length; + if (isPlainRecord(value)) return isPlainRecord(candidate); + return typeof candidate === typeof value && !isPlainRecord(candidate) && !Array.isArray(candidate); + }; + const matches = dropped.filter((key) => sameShape(authored[key])); + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * One round of {@link projectStorableViewBody}: the storable node at one + * position, built from the authored node, the parsed node, and — from the + * second round on — the re-parse of the previous round's body (`reparsed`) and + * that body itself (`previous`). + * + * - A key the author wrote and the parse kept is stored with its PARSED value, + * recursively. That is what normalises (`notEquals` → `not_equals`, + * `exportOptions: ['csv']` → `{ formats: ['csv'] }`) without storing a + * default a nested parse added. + * - A key the author wrote and the parse did not keep is not stored: an + * undeclared key, a console decoration, or the old spelling of a moved key. + * - A key the parse added is stored only on evidence. The previous round's body + * did not carry it, so if the re-parse of that body produced the same value, + * the parse applies it by itself — a schema DEFAULT, left to the parse + * (ADR-0087's `storable` rule). If the re-parse did NOT reproduce it, the value + * came from something the author wrote under another key — a MOVED key + * (`groups` → `sections`) — and it is stored, + * projected against the authored key it moved from when that key is the only + * one of its shape, and stored whole otherwise. Once stored it stays stored + * in later rounds, so the rounds only ever add. + * - Anything else — a value whose shape the parse changed (`['csv']` → + * `{ formats }`), an array whose length it changed, a leaf — is stored as + * parsed. + */ +function buildStorableViewNode(authored: unknown, parsed: unknown, reparsed: unknown, previous: unknown): unknown { + if (isPlainRecord(parsed) && isPlainRecord(authored)) { + const a = authored; + const r = isPlainRecord(reparsed) ? reparsed : undefined; + const prev = isPlainRecord(previous) ? previous : undefined; + const out: Record = {}; + const dropped: string[] = []; + for (const [key, value] of Object.entries(a)) { + if (value === undefined) continue; + if (parsed[key] === undefined) { + dropped.push(key); + continue; + } + out[key] = buildStorableViewNode(value, parsed[key], r?.[key], prev?.[key]); + } + for (const [key, value] of Object.entries(parsed)) { + if (value === undefined || key in out || a[key] !== undefined) continue; + const storedBefore = prev !== undefined && prev[key] !== undefined; + if (!storedBefore && (r === undefined || storableEqual(r[key], value))) continue; + // Projected against the key it moved from when that key is the only + // one of its shape; otherwise (a string folded into an expression + // object, as the retired `visibleOn` → `visibleWhen` fold did) the + // parsed value is the moved key's value, and it is stored whole. + const from = soleMovedFrom(dropped, a, value); + out[key] = from === undefined ? value : buildStorableViewNode(a[from], value, r?.[key], prev?.[key]); + } + return out; + } + if (Array.isArray(parsed) && Array.isArray(authored) && authored.length === parsed.length) { + const r = Array.isArray(reparsed) && reparsed.length === parsed.length ? reparsed : undefined; + const prev = Array.isArray(previous) && previous.length === parsed.length ? previous : undefined; + return parsed.map((entry, i) => buildStorableViewNode(authored[i], entry, r?.[i], prev?.[i])); + } + return parsed; +} + +/** + * [#20051] What the `view` write door STORES: the parsed value of every key the + * request body carried — undeclared keys dropped, schema defaults NOT + * materialised. Ruling 甲's end state ("a saved view is the parsed body") under + * letter B of its follow-up ruling, which puts views under the rule ADR-0087 + * (addendum 2026-08-01b) records for flows: `storable` excludes schema + * defaults, so a stored row never pins the day's default. + * + * It replaces, for `view`, the two grafts that ran over the verbatim body — + * {@link graftNormalizedOperators} (a value normalised in place) and + * {@link graftFoldedFormSections} (a key moved). Both are special cases of it: + * a parsed value is stored for every authored key, and a moved key is found by + * evidence rather than by name, so a move no graft names (the + * `visibleOn` → `visibleWhen` fold did, until that alias retired at 18) is + * covered too. Every other type keeps the two grafts over its verbatim body. + * + * ## Why a re-parse, and what it proves + * + * The parse output alone cannot say which of the keys it added are defaults and + * which are moved keys: `sections` folded from `groups` and `collapsible` + * defaulted to `false` look the same. So the body built from the authored keys + * is parsed again, and a key the parse added is kept exactly when that + * re-parse does NOT reproduce it (see {@link buildStorableViewNode}). The loop + * ends when the re-parse of the stored body EQUALS the parse of the request + * body — the invariant the stored row is held to: a GET → PUT of it is judged + * the same, and every reader that parses it sees what the save accepted. + * + * `converged: false` — the loop found no body meeting that invariant (a + * re-parse refused an intermediate body, or a round added nothing). The caller + * then stores `parsed` itself: no key is lost, only that row's defaults are + * pinned. Nothing in the `view` schema reaches that arm today; it is the + * fail-safe for a schema change that makes one of the rounds' bodies invalid. + */ +export function projectStorableViewBody( + authored: unknown, + parsed: unknown, + reparse: (body: unknown) => { ok: true; data: unknown } | { ok: false }, +): { body: unknown; converged: boolean } { + let candidate = buildStorableViewNode(authored, parsed, undefined, undefined); + for (let round = 0; round < STORABLE_VIEW_MAX_ROUNDS; round++) { + const again = reparse(candidate); + if (!again.ok) break; + if (storableEqual(again.data, parsed)) return { body: candidate, converged: true }; + const next = buildStorableViewNode(authored, parsed, again.data, candidate); + if (storableEqual(next, candidate)) break; + candidate = next; + } + return { body: parsed, converged: false }; +} + +/** + * [#20101] Give a page body that omits `type` the default `PageSchema` + * declares for it, and change nothing else. + * + * `PageSchema` declares `type: PageTypeSchema.default('record')`, so a page + * authored without `type` IS a record page. The save gate parses with that + * default and then persists the authored body verbatim (ADR-0005 appendix + * (c), {@link resolveOverlaySchema}), and every read serves a stored + * row without parsing it. A record page written the natural way, with the + * defaulted key left out, therefore reached every reader of the served body + * with no `type` at all. A consumer that picks an object's record page by + * `type === 'record'` never picked it. The parse applied the declared default + * and nothing the server sends carried it. + * + * One function, applied at both seams: + * - the WRITE (`saveMetaItem`, once the schema gate has accepted the body), + * so a new row stores the key and a GET → PUT round-trip of the served + * document stays byte-identical (#4326's invariant). A fix on the read + * side alone breaks that round-trip: the first re-save of every such page + * would write a one-key change and a history row that nobody authored; + * - the READ ({@link ObjectStackProtocolImplementation.convertStoredItem}, + * the rehydration seam every stored row passes through), so a row stored + * before this fix is served with the default and never rewritten. The row + * at rest keeps its bytes and there is no migration. + * + * The value is READ from the registered `page` schema, the one the save gate + * validates with, and is never spelled here. A literal would be a second, + * silent spelling of the default: the consumer-side `?? 'record'` this + * replaces, moved up one layer. + * + * ⛔ The scope is `type` on `page` and nothing wider. It is not a general + * "fill every declared default" pass. Other stored types declare top-level + * defaults in the same position, and each of those is its own decision. + * + * Only an ABSENT key is filled (zod applies a default to `undefined`, not to + * `null`), and an explicit value is left as it is. Returns the input itself + * when nothing is filled. + */ +export function withDeclaredPageTypeDefault(type: string, item: unknown): unknown { + if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'page') return item; + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + if ((item as Record).type !== undefined) return item; + const pageSchema = getMetadataTypeSchema('page') as { shape?: Record } | undefined; + const declared = pageSchema?.shape?.type?.safeParse(undefined); + if (!declared?.success || declared.data === undefined) return item; + return { ...(item as Record), type: declared.data }; +} + +/** + * #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view + * overlay is missing but the registry entry it shadows carries. The overlay's + * own fields always win. Returns `null` (nothing to inherit) for `defineView` + * container bodies — their identity is derived at expansion — and for + * absent/invalid baselines. + */ +function viewIdentityPatch(overlay: Record, baseline: unknown): Record | null { + if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) return null; + if ('list' in overlay || 'listViews' in overlay || 'formViews' in overlay) return null; + const b = baseline as Record; + const patch: Record = {}; + for (const key of ['viewKind', 'object', 'label'] as const) { + if (overlay[key] === undefined && b[key] !== undefined) patch[key] = b[key]; + } + return Object.keys(patch).length > 0 ? patch : null; +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { viewIdentityPatch }; diff --git a/packages/metadata-protocol/src/protocol-write-constants.ts b/packages/metadata-protocol/src/protocol-write-constants.ts new file mode 100644 index 00000000000..69ae061c826 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-write-constants.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the flow +// fallback verdict's not-replayed conversions and the clone path's stripped lifecycle columns. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. + +/** + * ADR-0087 conversions the save door's flow fallback verdict (#21982) does + * not replay, by id — the same DEFAULT-FLIP refusal the automation engine's + * canonicalizer makes (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in + * `service-automation/src/engine.ts`), because the fallback stands in for that + * canonicalizer and must reach the same verdict on the same body. + * + * - `flow-decision-mode-inclusive-explicit` (#15429) rewrites an omitted + * decision `mode` into `'inclusive'`, which is sound only where "this body + * predates the flip" is a fact; a save never knows that, exactly as the + * engine's canonicalizer does not. + */ +export const FLOW_FALLBACK_VERDICT_NOT_REPLAYED: readonly string[] = [ + 'flow-decision-mode-inclusive-explicit', +]; + +// Lifecycle columns the engine always owns; the clone path drops them by NAME +// so the insert re-stamps fresh values instead of copying the source's. Mirrors +// record-validator's SKIP_FIELDS (system-injected, never author-supplied). +const CLONE_STRIP_FIELDS: readonly string[] = [ + 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', +]; + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { CLONE_STRIP_FIELDS }; diff --git a/packages/metadata-protocol/src/protocol-write-face.ts b/packages/metadata-protocol/src/protocol-write-face.ts new file mode 100644 index 00000000000..37fa03843df --- /dev/null +++ b/packages/metadata-protocol/src/protocol-write-face.ts @@ -0,0 +1,468 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata +// write face and what a write is judged by: the spec validation findings, the shallow diff, +// destructive object change detection and its remedy. +// +// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood +// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot +// are new. `protocol.ts` re-exports what it exported. +import { + isVirtualSearchField, + type SearchFieldMeta, +} from '@objectstack/spec/data'; +import type { MetadataIssueEntry } from './protocol-metadata-issues.js'; +import { metadataIssueHeadline } from './protocol-client-failures.js'; + +/** + * [commit d806081dd] The `422 INVALID_METADATA` findings clause, rendered PER FACE. + * + * The refusal is raised in one place ({@link + * ObjectStackProtocolImplementation.saveMetaItem}'s overlay spec check) and + * quoted onto whatever response the caller's catch builds, so one clause is + * read on every face at once — and the faces do not agree on whether the + * structured `issues[]` array reaches the consumer beside it. Where it does, + * restating the prose here is the #10524 duplication exactly: every console + * rendering both channels shows each finding twice. Where it does NOT, this + * clause is the SOLE carrier and trimming it deletes the author's + * prescription from the wire outright. + * + * ## The polarity is the load-bearing decision, not the wording + * + * Silence renders the FULL prose — byte-identical to what every face carried + * before this card. Only a face that positively declares it carries the + * structured channel gets the headline. That direction is deliberate and is + * the opposite of a convenience default: + * + * - the destructive direction (deleting prose) requires an explicit, + * reviewable declaration at a call site, so it can never happen by + * omission; + * - a new write door added later, or one whose author never read this + * comment, keeps the complete sentence. The failure mode of forgetting is + * a redundant sentence on a console, not an author who is told a body is + * invalid and never told which key. + * + * ⛔ Do not invert this to "trim by default, message-only faces opt out". + * Measured on `origin/main` for THIS gate (see + * `protocol.invalid-metadata-422-face-inventory.test.ts`): four of the seven + * callers are message-only, and two of them live in OTHER packages + * (`@objectstack/runtime`'s ADR-0045 visibility flip, `plugin-security`'s + * permission-set projection) where no face can be stated without making the + * field caller-settable. Under the inverted polarity those two lose their + * prescription silently — and `plugin-security`'s remedy sentence + * ("make the record body spec-valid (the error names the offending key)") + * is written on the assumption that this clause names it. + * + * ⚠️ Unlike the 409's {@link destructiveChangeRemedy}, whose faces differ on + * WHAT REMEDY EXISTS, the faces here differ only on WHETHER A SECOND CHANNEL + * CARRIES THE SAME FACTS. Nothing is withheld from anyone: `err.issues` is + * attached identically on every face, and the headline still names how many + * findings there are and where they are. + */ +export function specValidationFindings( + face: MetadataWriteFace | undefined, + issues: MetadataIssueEntry[], +): string { + switch (face) { + case 'meta-envelope': + case 'meta-dispatch': + // The door answers with an ADR-0112 error envelope that carries + // `issues[]` beside the message (`@objectstack/rest`'s + // `sendError` threads a top-level `issues`; `@objectstack/runtime`'s + // dispatcher threads `details.issues`). The prose lives there, + // once. Same headline grammar the seed refusal and the author-time + // gate compose — count plus `path [zod code]` locators. + // + // ⚠️ [#11095] TWO faces, one case, on purpose. The dispatcher split + // off `'meta-envelope'` because the 409 needed a different REMEDY + // there — nothing about the 422 moved, and this door's structured + // channel (`details.issues`) is the very one the comment above + // names. Letting `'meta-dispatch'` fall to the default instead would + // have re-introduced the duplication commit d806081dd removed on one door only, with + // every 409 test green: the polarity below is "declare to trim", so + // a face that stops declaring loses the trim SILENTLY. Pinned in + // `protocol.destructive-409-face-inventory.test.ts`'s [#11095] + // section, which asserts the 422 clause did not move under it. + return metadataIssueHeadline(issues); + default: + // Byte-identical to the clause before commit d806081dd: the first three findings + // as `: `, then a `(+N more)` tail. Read by + // `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s + // `rows[].reason`, `importUnmanagedPackage`'s rolled-back refusal + // (`'package-unmanaged-import'`, whose one door relays no + // `issues[]` — see `MetadataWriteFace`), and the two + // out-of-package log faces — none of which carry `issues[]` to + // their caller. + return issues.slice(0, 3) + .map((i: { path: string; message: string }) => `${i.path || ''}: ${i.message}`) + .join('; ') + + (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''); + } +} + +/** + * Phase 3a-destructive: detect changes between an existing object schema + * and an incoming overlay that would break runtime data — removed fields, + * field type narrowing, required toggled on without a default, a stored + * amount switched to `currencyMode: 'record'` (rows left codeless). Returned + * issues are surfaced as HTTP 409 `destructive_change` unless the caller + * sets `force: true`, letting the admin UI render a warning dialog before + * proceeding. + * + * Scope is intentionally narrow for MVP: covers the most common + * data-loss footguns for `object` and `field` types. Subsequent passes + * can layer in relationship changes, enum-value removals, etc. + */ +/** + * Shallow JSON diff used by `diffMetaItem`. Compares the top-level + * keys of `from` vs `to`; primitive value changes are reported as + * `changed`, nested objects/arrays that differ structurally are also + * reported as a single `changed` entry (deep structural diffs are out + * of scope — Studio renders the full bodies for a side-by-side view). + */ +function diffShallow( + from: Record, + to: Record, +): { + added: Array<{ path: string; value: unknown }>; + removed: Array<{ path: string; value: unknown }>; + changed: Array<{ path: string; from: unknown; to: unknown }>; +} { + const added: Array<{ path: string; value: unknown }> = []; + const removed: Array<{ path: string; value: unknown }> = []; + const changed: Array<{ path: string; from: unknown; to: unknown }> = []; + const fromKeys = new Set(Object.keys(from ?? {})); + const toKeys = new Set(Object.keys(to ?? {})); + for (const k of toKeys) { + if (!fromKeys.has(k)) { + added.push({ path: k, value: (to as any)[k] }); + } else { + const a = (from as any)[k]; + const b = (to as any)[k]; + const aStr = JSON.stringify(a); + const bStr = JSON.stringify(b); + if (aStr !== bStr) { + changed.push({ path: k, from: a, to: b }); + } + } + } + for (const k of fromKeys) { + if (!toKeys.has(k)) { + removed.push({ path: k, value: (from as any)[k] }); + } + } + return { added, removed, changed }; +} + +function detectDestructiveObjectChanges(prev: any, next: any): Array<{ + code: string; + field?: string; + message: string; +}> { + if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') return []; + const prevFields = (prev.fields && typeof prev.fields === 'object') ? prev.fields as Record : {}; + const nextFields = (next.fields && typeof next.fields === 'object') ? next.fields as Record : {}; + + const issues: Array<{ code: string; field?: string; message: string }> = []; + + // [#24411] Every finding below names STORED data a save makes unreachable, + // so a field with no stored column (a `formula`, computed on read — + // ADR-0079 addendum, item 9) loses nothing when it is removed or retyped. + // "Stored" is the spec's storage predicate — the one the sortability + // projection reads — judged from the field's own declaration, ⛔ never a + // type list kept here. A false finding trains callers to send `force=true` + // by reflex, which silences the true one on a stored field. + const isStored = (field: SearchFieldMeta | undefined): boolean => !isVirtualSearchField(field); + + // Removed fields — silently dropping a column is a data-loss event. + for (const fname of Object.keys(prevFields)) { + // Skip system fields — those are managed by applySystemFields and + // re-injected on every registerObject call; they will look "removed" + // in any user-supplied overlay. + if (prevFields[fname]?.system) continue; + if (!isStored(prevFields[fname])) continue; + if (!(fname in nextFields)) { + issues.push({ + code: 'field_removed', + field: fname, + message: `Field '${fname}' removed — existing data in this column will become inaccessible.`, + }); + } + } + + // Field type changes — narrowing or incompatible conversions. + const TYPE_COMPATIBILITY: Record> = { + text: new Set(['textarea', 'markdown', 'html', 'code']), + number: new Set([]), + boolean: new Set([]), + date: new Set(['datetime']), + datetime: new Set(['date']), + }; + for (const fname of Object.keys(nextFields)) { + const prevField = prevFields[fname]; + const nextField = nextFields[fname]; + if (!prevField) continue; // brand-new field — non-destructive + const prevType = prevField.type; + const nextType = nextField.type; + // Judged by what the STORED side loses: an unstored previous field held + // no values, so retyping it into anything starts from an empty column. + if (prevType && nextType && prevType !== nextType && isStored(prevField)) { + const compatible = TYPE_COMPATIBILITY[prevType]?.has(nextType); + if (!compatible) { + issues.push({ + code: 'field_type_change', + field: fname, + message: `Field '${fname}' type changed from '${prevType}' to '${nextType}' — existing values may not convert cleanly.`, + }); + } + } + // required toggled on without a default — new inserts will start + // to fail validation, and any null rows already in the table will + // fail on next save. + if (!prevField.required && nextField.required && nextField.defaultValue === undefined) { + issues.push({ + code: 'field_required_no_default', + field: fname, + message: `Field '${fname}' is now required but has no default value — existing rows with null values may fail validation.`, + }); + } + // [ADR-0144, ruling 6492000994] An existing amount switched to + // `currencyMode: 'record'` — the sibling of `field_required_no_default`: + // a one-word config edit that leaves every stored row owing a value. + // The rows already stored hold an amount and no code; the switch is + // allowed (the write guard refuses such a row only when a write + // touches the pair), so this finding acknowledges it and names the + // explicit backfill. Like its siblings it reads no row: whether the + // table holds any is not this gate's to judge, and a finding over an + // empty table costs one `force`. Judged by what the STORED side holds, + // so a previously unstored field (a formula) raises nothing. + if ( + nextField?.type === 'currency' + && nextField.currencyConfig?.currencyMode === 'record' + && prevField.currencyConfig?.currencyMode !== 'record' + && isStored(prevField) + ) { + const objectName = typeof next.name === 'string' && next.name ? next.name : prev.name; + const codeField = typeof nextField.currencyConfig.currencyField === 'string' + ? `'${nextField.currencyConfig.currencyField}'` + : 'its currency code field'; + issues.push({ + code: 'currency_mode_to_record', + field: fname, + message: `Field '${fname}' switched to currencyMode 'record' — rows already stored with an amount and a blank ${codeField} ` + + 'display as a plain number, fall into the null group of a grouped aggregate, and sit outside every ' + + 'currency-paired filter until they hold a code; once saved, write it with ' + + `\`os migrate record-currency-codes --object ${objectName} --field ${fname} --code \`` + + ' (a dry run; add --apply to write).', + }); + } + } + return issues; +} + +/** + * [commit 82cb6e849] The remedy clause the Phase 3a-destructive refusal ends with — one + * sentence per FACE, because the mechanism that lifts the refusal is not the + * same on every door that raises it. + * + * The refusal is raised in ONE place ({@link ObjectStackProtocolImplementation.saveMetaItem}'s + * Phase 3a-destructive gate) and is quoted onto whatever response the caller's + * catch builds, so the clause used to read `re-submit with ?force=true to + * proceed.` on every face. That is correct prose for exactly one of them — + * `PUT /api/v1/meta/:type/:name`, whose route reads `?force` and threads it + * into the request — and a caller on any OTHER face who does what the sentence + * says gets the identical refusal back. + * + * MEASURED on the face this card was filed on: `POST /packages/:id/duplicate` + * accepts `targetPackageId`, `targetName`, `targetNamespace`, `organizationId` + * and `actor` — no `force`, in the query string or the body — and + * {@link ObjectStackProtocolImplementation.duplicatePackage}'s own request type + * has no `force` field either, so its internal `saveMetaItem` call cannot carry + * one. There is nothing on that door for the caller to set. + * + * ⛔ What is repaired is the CLAUSE, not the door. Giving the duplicate route a + * `force` would widen a public surface and is a contract decision, deliberately + * NOT taken here. Nor is the clause deleted on that face: commit 809e61221 measured that + * `duplicatePackage`'s `failed[].error` is the SOLE carrier of this + * prescription, so deleting the remedy there deletes it from the wire outright. + * Each face therefore states the remedy it actually has. + * + * ⚠️ An absent `face` renders the `?force=true` wording, byte-identical to what + * every face carried before. That default is right on BOTH REST `PUT` doors and + * on nothing else that reaches this gate today — see the `[#11095]` section of + * `protocol.destructive-409-face-inventory.test.ts` for the full inventory. + * + * ## [#11095] The two doors commit 82cb6e849 left open, and why they were split + * + * Commit 82cb6e849's note recorded two further faces that reached this gate and never + * threaded `force`, and filed the disposition as a contract question rather + * than guessing it. The ruling was a SPLIT — one door repaired by threading the + * parameter, the other by telling the truth — and the split is the decision, + * not an accident of convenience: + * + * - `@objectstack/rest`'s compound-name `PUT /meta/:type/:a/:b` now THREADS + * `?force=true`, so the default clause became TRUE there instead of being + * reworded. That half is #7019's ruling applied once more, with its reason: + * the compound route is "word for word the same operation" as its + * single-segment twin — one generic `saveMetaItem` reached by a name spelled + * in two segments — and gating only the single-segment door was MEASURED to + * leave the compound one a bypass of it. Every divergence found between the + * pair since has been closed for that same reason (#6603/#7019's capability + * gate, #8805's write-side organization, #7035's 501 envelope). One door + * reading `?force` and its literal twin ignoring it was the same shape. + * - `@objectstack/runtime`'s dispatcher `PUT /meta` does NOT gain `force`, and + * states `'meta-dispatch'` below instead. It has no twin precedent and a + * different call shape: that branch is reached with a path, a method and a + * body — there is no query string on the transport at all, so `?force=true` + * names a slot that does not exist rather than one that was forgotten. Its + * clause says so, and prescribes what a caller can actually do there. + * + * ⛔ Do not "harmonise" these two later by giving the dispatcher a `force` + * field. The compound door's parity argument is a statement about ONE pair of + * routes that spell one name two ways; it is not a general licence, and the + * dispatcher is not the third member of that pair. + * + * ## [#21841] The external-table import, and why it went the dispatcher's way + * + * "Import as Object" (`@objectstack/service-datasource`) saves through this + * gate since #21788, so a re-import that would shrink an object it created is + * refused here and relayed as `400 EXTERNAL_IMPORT_ERROR`. The import route + * reads no `force`, so the default clause sent that caller round in a circle. + * It states `'external-import'` and gets a clause of its own rather than a + * `force`, for the reasons the dispatcher did and one more: the route has no + * twin that reads `?force`, and no first-party caller re-imports through it + * (the console's import dialog saves its draft through + * `PUT /api/v1/meta/object/:name`, the very door this clause names), so a + * `force` there would be a new capability with nothing pulling on it. Unlike + * the dispatcher, a door that DOES read `?force` exists for the same item, so + * the clause names it instead of only telling the caller to reconcile. + * + * ⛔ Same warning as above: the import is not a twin of the `PUT` door, and + * giving it a `force` to "match" would be a new surface, not a repair. + */ +/** + * [commit 82cb6e849 / commit d806081dd] Which write door a `saveMetaItem` refusal is being + * rendered FOR. Stated by the SERVER — either by the protocol's own internal + * call ({@link ObjectStackProtocolImplementation.duplicatePackage}) or by the + * in-process HTTP boundary that owns the response envelope — never by a remote + * caller: no write door spreads a request body into the `saveMetaItem` request + * object, so there is no path for a client to smuggle a face in (pinned in + * both face-inventory suites). + * + * Two refusals read this, and they read it for DIFFERENT questions: + * + * - {@link destructiveChangeRemedy} (409) — which remedy actually exists on + * this door; + * - {@link specValidationFindings} (422) — whether a structured `issues[]` + * channel reaches the consumer beside the message. + * + * A door therefore answers both questions by naming itself once, and neither + * switch may assume the other's default. `'meta-envelope'` deliberately keeps + * the 409's `?force=true` wording (it is both REST `PUT` doors' genuine remedy, + * since #11095 threaded the parameter on the compound-name twin) while changing + * the 422's clause. + * + * ⚠️ [#11095] `'meta-dispatch'` is the first face that differs from another on + * ONE of the two questions and agrees with it on the other: the runtime + * dispatcher carries `issues[]` structurally exactly as the REST doors do + * (`errorFromThrown` → `details.issues`), so its 422 clause is `'meta-envelope'`'s, + * while its 409 clause must differ because that door has no `force` to name. + * That is the two-switch independence this comment asserts, arriving as a real + * case rather than a hypothetical — which is why {@link specValidationFindings} + * lists the two faces on one `case` instead of letting `'meta-dispatch'` fall + * to a default that was never written for it. + * + * ⚠️ [#21841] `'external-import'` differs from `'meta-dispatch'` on that SAME + * second question, in the other direction: the import route relays a refusal + * as `sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', message)` and nothing else, + * so no `issues[]` reaches its caller and the message is the sole carrier of + * the findings. It therefore stays OUT of {@link specValidationFindings}' + * trimming case and keeps the full-prose default — the polarity that comment + * defends, working as designed for a door added after it. + * + * ⚠️ [ADR-0131 D6] `'package-unmanaged-import'` is stated by + * {@link ObjectStackProtocolImplementation.importUnmanagedPackage}, the + * `unmanaged` install mode's copy-once import, for every item it writes. Its + * refusal is relayed by that verb's own thrown error. The thrown value does + * forward the save door's `issues` (the rollback keeps the cause's code, + * status and issues), but the one door that calls the verb — + * `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'` + * (`@objectstack/cloud-connection`) — answers with the message, the code and + * `details` only, so no `issues[]` reaches its caller beside the message and + * the message is the sole carrier of the findings: it keeps the full-prose + * default on the 422, the polarity `'external-import'` keeps for the same + * reason. A door that starts relaying `issues` moves this face into + * {@link specValidationFindings}' trimming case in the same change. Its 409 + * clause is its own: the import accepts no `force`, and it writes only names + * no environment row held when it began. + */ +export type MetadataWriteFace = + | 'package-duplicate' + | 'meta-envelope' + | 'meta-dispatch' + | 'external-import' + | 'package-unmanaged-import'; + +function destructiveChangeRemedy( + face: MetadataWriteFace | undefined, + name: string, +): string { + switch (face) { + case 'package-duplicate': + // The duplicate-AGAIN workflow: the target namespace already holds + // the renamed item this copy is about to overwrite. Both remedies + // are things the caller can actually do on THIS door — choose a + // free target namespace, or make the collision non-destructive. + return `this copy cannot be forced: the duplicate door accepts no \`force\`. ` + + `Duplicate into a target namespace that does not already hold '${name}', ` + + `or reconcile that item with the source first.`; + case 'meta-dispatch': + // [#11095] The runtime dispatcher's `/meta` PUT. Unlike the two REST + // `PUT` doors — which read `?force` off a query string and, since + // this card, BOTH thread it — this branch is reached with a path, a + // method and a body and nothing else. `?force=true` does not name a + // parameter this door forgot to read; it names a channel the + // transport does not have, which is why threading it here would be + // a new surface rather than a repair, and was ruled out. + // + // Both clauses are things the caller can do on THIS door: send a + // body that does not drop what the stored item still carries, or go + // and reconcile that item first. Same grammar as the duplicate face + // above — name the door, deny the mechanism, then prescribe. + return `this save cannot be forced: the dispatcher's \`PUT /meta\` accepts no \`force\`. ` + + `Re-submit '${name}' with a body that keeps the fields and types named above, ` + + `or reconcile that stored item first.`; + case 'external-import': + // [#21841] "Import as Object" (`@objectstack/service-datasource`), + // relayed by the import route as `400 EXTERNAL_IMPORT_ERROR`. That + // route reads no `force` and was deliberately not given one (see the + // section above `MetadataWriteFace`). Both remedies are things the + // caller can do from where they stand: import the table under a + // name nothing stores yet, or save the changed definition through + // the metadata door, which reads `?force=true` for this very item + // and is where a destructive change is acknowledged on purpose. + // Same grammar as the two faces above — name the door, deny the + // mechanism, then prescribe. + return `this import cannot be forced: the external-table import route accepts no \`force\`. ` + + `Import the table under a new \`name\`, or save the changed definition of '${name}' ` + + `through \`PUT /api/v1/meta/object/${name}?force=true\`, which accepts the destructive change on purpose.`; + case 'package-unmanaged-import': + // [ADR-0131 D6] The `unmanaged` install mode's copy-once import. + // It refuses every name an environment row already holds before + // it writes anything, so reaching this gate means a row appeared + // under the name while the import ran. The import accepts no + // `force`; what the caller can do is reconcile the item that + // appeared, then import again. What became of the base is the + // verb's own sentence around this one, which it writes only once + // it has read the rollback's outcome — never claimed here. Same + // grammar as the faces above — name the door, deny the mechanism, + // then prescribe. + return `this import cannot be forced: the unmanaged install accepts no \`force\`. ` + + `Reconcile the environment item '${name}' that appeared while the import ran, then install again.`; + default: + return 're-submit with ?force=true to proceed.'; + } +} + +// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move. +export { destructiveChangeRemedy, detectDestructiveObjectChanges, diffShallow }; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8c383a7cc58..a8a1ff2ae92 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1,30 +1,21 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - DataProtocol, FlowRegistration, MetadataProtocol, PackageProtocol, + DataProtocol, + FlowRegistration, + MetadataProtocol, + PackageProtocol, } from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, recordNotFoundError } from '@objectstack/core'; -import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, resolveTenancyPosture, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types'; -import { judgePermissionSetOwnerBinding, type TenancyPosture } from '@objectstack/spec/security'; +import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; +import { type TenancyPosture } from '@objectstack/spec/security'; // [commit 376c70f98] The derived `version` this file's `getDiscovery()` serves as the // `DiscoverySchema` "System Identity" field — never a literal again. import { resolveDiscoveryVersion } from './discovery-version.js'; import type { MetadataHostEngine } from './host-engine.js'; import { omitInternalFieldsFromWriteResponse } from './write-response-internal-fields.js'; import { staleSummaryRefusalOrThrow, withEarnedAnswer, type SummaryRecomputeRefusal } from '@objectstack/types'; -// [#17502] The served JSON Schema publishes what an author MAY write, so a -// property no instance can satisfy — a `retiredKey()` tombstone, rendered -// `{ not: {} }` — is dropped from it. See the module header for the channels -// that keep carrying the retirement's prescription. -import { stripUnauthorableProperties } from './unauthorable-nodes.js'; -// [#19295] The output derivation of a `ZodPipe` erases the arm's own input -// type, so an authoring arm that accepts a bare string is served as the -// anonymous `{}`. This annotates that husk — and only that husk — so a -// consumer can tell an ERASED authoring type from a member that genuinely -// admits anything. See the module header for the predicate and its controls. -import { markErasedAuthoringInput } from './erased-authoring-mark.js'; import { - SDUI_MANIFEST_SERVICE, stampHtmlPageRequires, type RuntimePendingDeclarations, } from './runtime-authoring-gate.js'; @@ -32,9 +23,6 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. import { - backfillOverlayBundleMembers, - overlayMemberOfBody, - overlayMemberValue, overlayMemberWhere, } from './migrations/overlay-index.js'; import { @@ -45,14 +33,12 @@ import { packageScopedRowWhere, packagelessAddressAmbiguityRefusal, resetEnvWritableMetadataTypes, - unboundItemDraftAddress, versionNotFoundError, type SysMetadataEngine, } from './sys-metadata-repository.js'; import { managedItemSealedSentence, packagedBaseRegimeRow, packagedBaseRegimeSentence } from './packaged-base-regime.js'; import { resolveItemLock, - resolveItemLockLazily, resolveOverlayLockLayer, storedRowDocument, withItemLockFamily, @@ -66,11 +52,6 @@ import { import { ConflictError, assertProtocolCompat, - applyAuditFieldGovernance, - // [#6562] The injection/strip pair over the shared injected-column - // definition table — see {@link governServedItem} / {@link stripServedSystemColumns}. - applyInjectedSystemColumns, - stripInjectedSystemColumns, // [#7774] The i18n-bundle identity table (#7730) and its reader. A type // listed here is identified by `(name, )`, so every merge in the // unscoped `/meta` list keys on the pair — see {@link mergePackageAwareOverlay}. @@ -126,34 +107,30 @@ import type { import { ImportUnmanagedPackageRequestSchema } from '@objectstack/spec/api'; import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; -import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; -import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; +import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; // [#24395] The save's authoring surface, and the draft row's per-key // provenance read back for the strict draft read. -import { MetadataAuthoringSurfaceSchema, type MetadataAuthoringClient, type MetadataAuthoringSurface } from '@objectstack/spec/api'; +import { MetadataAuthoringSurfaceSchema } from '@objectstack/spec/api'; import { authoringClientOf, storedDraftChanges } from './draft-changes.js'; import { - parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, - resolveSearchFieldResolution, - isVirtualSearchField, - type SearchFieldMeta, + parseFilterAST, + isFilterAST, foldQueryAliasSlots, - QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS, - QueryPageWindowParamSchema, - QueryTransportParamsSchema, - type QueryAliasConflict, - type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, + QUERY_TRANSPORT_ALIAS_SLOTS, + QUERY_TRANSPORT_DOLLAR_ALIASES, + QUERY_TRANSPORT_DOLLAR_PARAMS, + type DroppedFieldsEvent, + type EngineQueryOptionsParsed, type ValidationAdvisoryEvent, - externalBindingDatasourceRefusal, } from '@objectstack/spec/data'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; // [#13331] The cluster fan-out transport type only — the protocol never // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. import type { IFieldFileService, IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts'; import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts'; import { applyConversionsToFlow, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; -import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; +import { type I18nLabel, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; // [commit ece4dad31] Emitted-specifier pin. This module's inferred public declarations // structurally mention `FormFieldInput` (FormView `sections[].fields`), and // this file imports BOTH `@objectstack/spec` (root, for @@ -171,7 +148,7 @@ import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewCon // (curated entry, unchanged). import { type FormFieldInput } from '@objectstack/spec/ui'; export type { FormFieldInput }; -import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; +import { CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import { resolveLockState, @@ -203,16 +180,6 @@ import { import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import { REFERENCE_SITES } from './reference-sites.js'; -// [#5488] The `@objectstack/spec/api` import that stood here — `ApiEndpointSchema`, -// `validateApiEndpointDeclarations`, `type ApiEndpoint` — went with -// `gateApiDraftsForPublish` (see its retirement note in `publishPackageDrafts`). -// This module no longer judges endpoints at all: `api` is code-only since #5488, -// so no `api` draft can be authored here, and the ONE judge of what is servable -// stays `validateApiEndpointDeclarations` on the artifact route (stack schema, -// `publishPackage` #5189, and `buildEndpointIndex` at load, PR #5203). An -// endpoint-shaped import left behind with no caller reads as a capability this -// module still has (#3950), so it is removed rather than parked. -import { z } from 'zod'; import { computeMetadataDiagnostics, computeViewReferenceDiagnostics, @@ -237,16 +204,11 @@ import { storedMetadataBodyGroupingRefusal, storedMetadataBodyPredicateRefusal, storedMetadataBodyProjection, - // [#21207] The stored content hash of the same rows: served keyed, never - // evaluated — see `STORED_METADATA_HASH_COLUMNS`. - ephemeralStoredHashDigest, - isStoredMetadataBodyObject, servedContentHash, serveStoredHashTokens, serveStoredMetadataHashColumnRows, serveStoredMetadataHashColumns, storedMetadataHashEvaluateRefusal, - storedMetadataSearchRefusal, type StoredHashDigest, } from './metadata-redaction.js'; import type { @@ -424,10 +386,8 @@ import { flowSaveReplaysRetired } from './protocol-flow-save-replay.js'; 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'; @@ -494,6260 +454,277 @@ import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-crede import { withRefusalAudit } from './protocol-refusal-audit.js'; // [#28665] The null-master rows a `controlled_by_parent` detail stops serving, counted into the finding that makes its master required. import { withNullMasterRowCounts } from './protocol-destructive-null-master.js'; +// [#25467 E0] The module preamble that stood here moved, verbatim, into the themed +// `protocol-*.ts` modules below. What this file exported from it, it still exports. +import { + HAND_CRAFTED_SCHEMAS, + TYPE_TO_FORM, + canonicalMetaType, + canonicalizeMetaRequestType, + overlaySchemaDeclaredKey, + resolveOverlaySchema, + toJsonSchemaSafe, +} from './protocol-meta-type-schema.js'; +export { + canonicalMetaType, + governServedItem, + overlaySchemaDeclaredKey, + stripServedSystemColumns, +} from './protocol-meta-type-schema.js'; +import { + isActionWithheldByDisabledPackage, + isItemOfDisabledPackage, + isObjectOfDisabledPackage, + noPendingDraftError, + objectPackageDisabledError, + packagelessPublishNoDraftRefusal, + permissionSetOwnerBindingRefusal, + protectionBlockRefusal, + remainingRowsPhrase, + runtimeHookWithoutBodyRefusal, + withoutProtectionBlock, +} from './protocol-authoring-refusals.js'; +export { + isActionWithheldByDisabledPackage, + isItemOfDisabledPackage, + isObjectOfDisabledPackage, + noPendingDraftError, + objectPackageDisabledError, + permissionSetOwnerBindingRefusal, + remainingRowsPhrase, + runtimeHookWithoutBodyRefusal, +} from './protocol-authoring-refusals.js'; +import { zodIssuesToMetadataIssues } from './protocol-metadata-issues.js'; +import type { MetadataIssueEntry } from './protocol-metadata-issues.js'; +export { zodIssuesToMetadataIssues } from './protocol-metadata-issues.js'; +export type { MetadataIssueEntry } from './protocol-metadata-issues.js'; +import { readPublishBackfillRequest } from './protocol-publish-backfill-request.js'; +import type { PublishBackfillEntry, PublishBackfillReceipt } from './protocol-publish-backfill-request.js'; +export { PUBLISH_BACKFILL_PAGE_SIZE, publishBackfillRefusal } from './protocol-publish-backfill-request.js'; +export type { PublishBackfillEntry, PublishBackfillReceipt } from './protocol-publish-backfill-request.js'; +import { byIdUpdateRecord, malformedFilterArrayError } from './protocol-data-request-shapes.js'; +export { byIdUpdateRecord } from './protocol-data-request-shapes.js'; +import { + graftFoldedFormSections, + graftNormalizedOperators, + normalizeViewMetadata, + projectStorableViewBody, + viewIdentityPatch, + withDeclaredPageTypeDefault, +} from './protocol-view-normalization.js'; +export { + graftFoldedFormSections, + graftNormalizedOperators, + isPlainRecord, + normalizeViewMetadata, + projectStorableViewBody, + withDeclaredPageTypeDefault, +} from './protocol-view-normalization.js'; +import { + boundPackageOf, + bundleMemberOf, + envelopePackageId, + isShippedViewItem, + mergeArtifactProtection, + perPackageLayerEnvelope, + stateTenantAuthorship, + stripDerivedProvenance, + withRowBinding, +} from './protocol-package-bindings.js'; +import type { + HydrationOptions, + ShippingPackages, + UnboundOwnerRefusalState, + ViewContainerWrite, +} from './protocol-package-bindings.js'; +export { withRowBinding } from './protocol-package-bindings.js'; +export type { ShippingPackages } from './protocol-package-bindings.js'; +import { + bundleMemberDiscriminator, + commitPlanKey, + memberOption, + mutationEventMember, + mutationEventMemberOfBody, + resolveBundleMemberSelector, +} from './protocol-bundle-members.js'; +import type { CommitPlanItem } from './protocol-bundle-members.js'; +export { + BUNDLE_MEMBER_MAX_LENGTH, + BUNDLE_MEMBER_SELECTOR, + bundleMemberDiscriminator, + memberOption, + resolveBundleMemberSelector, +} from './protocol-bundle-members.js'; +export type { CommitPlanItem } from './protocol-bundle-members.js'; +import { mergePackageAwareOverlay, rowMatchesOverlayWhere, servedViewExpansion } from './protocol-overlay-merge.js'; +export { + addressPackages, + mergePackageAwareOverlay, + rowMatchesOverlayWhere, + servedOverlayRowCandidates, + servedViewExpansion, +} from './protocol-overlay-merge.js'; +import { compareAuditInstants, isoFromValidDate, simpleHash } from './protocol-version-tokens.js'; +export { + ConcurrentUpdateError, + MalformedVersionTokenError, + canonicalVersionInstant, +} from './protocol-version-tokens.js'; +import { CLONE_STRIP_FIELDS, FLOW_FALLBACK_VERDICT_NOT_REPLAYED } from './protocol-write-constants.js'; +export { FLOW_FALLBACK_VERDICT_NOT_REPLAYED } from './protocol-write-constants.js'; +import { + carryCatalogedErrorCode, + carryDeclaredUserMessage, + clientFacingFailureCode, + clientFacingFailureText, + declaresClientRefusal, + metadataIssueHeadline, + metadataItemNotFoundError, + metadataReadFailureError, + overlayDeleteFailureMessage, +} from './protocol-client-failures.js'; +import type { BatchDataLoopOutcome, BatchDataRowResult } from './protocol-client-failures.js'; +export { + clientFacingFailureCode, + clientFacingFailureText, + declaresClientRefusal, + metadataReadFailureError, + metadataStoreUnavailableError, + rowRequiredIdError, + seedRequestValidationError, + toRowApiError, +} from './protocol-client-failures.js'; +export type { BatchDataLoopOutcome, BatchDataRowResult } from './protocol-client-failures.js'; +import { + destructiveChangeRemedy, + detectDestructiveObjectChanges, + diffShallow, + specValidationFindings, +} from './protocol-write-face.js'; +import type { MetadataWriteFace } from './protocol-write-face.js'; +export { specValidationFindings } from './protocol-write-face.js'; +export type { MetadataWriteFace } from './protocol-write-face.js'; +import { + RESERVED_LIST_QUERY_PARAMS, + assertQueryParamArity, + collectStoredMetadataFilterFields, + conflictingQueryParamsError, + countFlagValue, + narrowStoredMetadataSearch, + normalizeSortNodes, + pageWindowValue, + projectionFieldsValue, + unusableFilterError, +} from './protocol-list-query-params.js'; +export { + FILTER_LOGICAL_KEYS, + ODATA_SPELLING, + QUERY_PARAM_NEAR_MISS, + collectFilterReads, + collectStoredMetadataFilterFields, + invalidSortError, + narrowStoredMetadataSearch, + nearMissKey, + quotedQueryValue, + suggestFieldName, +} from './protocol-list-query-params.js'; +export type { StoredMetadataSearchSchema } from './protocol-list-query-params.js'; +import { SERVICE_CONFIG } from './protocol-discovery-services.js'; +import { collectCompositeKeyHeadHits, collectReferenceHits } from './protocol-reference-hits.js'; +import type { + DeletePackageRequest, + DeletePackageResponse, + PublishMaterializeResult, + PublishMaterializer, + UninstallCleanup, + UninstallCleanupOutcome, + UninstallDeclared, +} from './protocol-package-lifecycle-types.js'; +export { captureUninstallDeclared } from './protocol-package-lifecycle-types.js'; +export type { + DeletePackageRequest, + DeletePackageResponse, + PublishMaterializeResult, + PublishMaterializer, + UninstallCleanup, + UninstallCleanupOutcome, + UninstallDeclared, +} from './protocol-package-lifecycle-types.js'; +import { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol-mutation-events.js'; +import type { + ClusterMetadataMutationPayload, + MetaItemPublishedEvent, + MetadataMutationEvent, + MetadataMutationProjector, + MutationProjectionOutcome, +} from './protocol-mutation-events.js'; +export { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol-mutation-events.js'; +export type { + ClusterMetadataMutationPayload, + MetaItemPublishedEvent, + MetadataMutationEvent, + MetadataMutationProjector, + MutationProjectionOutcome, +} from './protocol-mutation-events.js'; +import type { + MetadataAuthoringChannel, + MetadataAuthoringGate, + MetadataCredentialChannel, +} from './protocol-authoring-channel.js'; +export type { + MetadataAuthoringChannel, + MetadataAuthoringGate, + MetadataAuthoringGateContext, + MetadataCredentialChannel, +} from './protocol-authoring-channel.js'; +import { PREFLIGHT_AUDIT_CODE, withPendingAudit } from './protocol-audit-entry.js'; +import type { MetadataAuditEntry, PreflightViolationCode } from './protocol-audit-entry.js'; +export type { MetadataAuditEntry } from './protocol-audit-entry.js'; +import { isNonCanonicalStoredType, storedEntryRow } from './protocol-stored-overlay-types.js'; +import type { RowlessExpandedView, StoredOverlayEntry } from './protocol-stored-overlay-types.js'; +export type { RowlessExpandedView, StoredOverlayEntry } from './protocol-stored-overlay-types.js'; +import type { + DeleteMetaItemRequest, + PublishMetaItemRequest, + RollbackMetaItemRequest, + SaveMetaItemRequest, +} from './protocol-door-requests.js'; /** - * Canonical Zod schema per metadata type lives in - * `@objectstack/spec/kernel/metadata-type-schemas` and is exposed through - * {@link getMetadataTypeSchema}. Both save-time validation - * ({@link resolveOverlaySchema}) and the `/meta/types/:type` JSON Schema - * emitter consult that single source of truth, so adding a new - * metadata-type schema requires editing exactly one file (or calling - * `registerMetadataTypeSchema()` from a plugin). - */ -// (TYPE_TO_SCHEMA removed — use `getMetadataTypeSchema(type)` directly.) - -/** - * Canonical {@link FormView} layout per metadata type. Sourced from the - * shared {@link METADATA_FORM_REGISTRY} in `@objectstack/spec/system` so - * the runtime form payload, the i18n extractor, and Studio all read from - * a single source of truth. - * - * Types without an entry render with the auto-generated single-section - * layout derived from their JSON Schema (acceptable for simple types). - */ -const TYPE_TO_FORM: Readonly> = METADATA_FORM_REGISTRY; - -/** - * The ONE canonical spelling of a metadata type at the `/meta` read/write/delete - * boundary (#4432). - * - * Prime Directive #3 already fixes the answer — metadata type names are - * SINGULAR (`'action'`, `'view'`), REST paths are plural (`/meta/actions`) — and - * #3985 taught the per-type gates to accept both spellings. What it did not do - * is fold them, so the two spellings addressed two different namespaces and the - * layers below disagreed about which one an item lived in: - * - * - the `SysMetadataRepository` write/delete path already folded to singular, - * while the authorization tier above it (`isOverlayAllowed`, - * `isArtifactBacked`) and the registry heal below it - * (`restoreArtifactRegistryView`) read the caller's spelling; - * - `getMetaItems` registered overlay rows back into the SchemaRegistry under - * the caller's spelling. One plural-spelled read minted a plural registry - * entry, `listItems('actions')` stopped being empty, and the singular - * fallback that had been supplying the code-authored items never ran again — - * so one overlay row shadowed an entire code-authored listing, and survived - * the DELETE that was supposed to lift it. - * - * Folding at the boundary (rather than adding another spelling-tolerant lookup - * one layer down) is Prime Directive #12 applied to a type key: one contract, - * not N dialects. Reads of data AT REST still try the other spelling as a - * fallback — rows written under a plural `type` before this fix are real, and - * nothing rewrites them on upgrade. - * - * ## [#7894] It folds through the URL map, NOT the manifest map - * - * This used to read `PLURAL_TO_SINGULAR`, which is a MANIFEST-COLLECTION map - * (`objects: [...]`, `apps: [...]`) that `metadata-authoring-lint.ts` iterates - * to decide which stack-level collections exist. Four registry types are - * legitimately absent from it because they are not stack collections — - * `field`, `seed`, `external_catalog`, `translation`. At this boundary that - * absence did not read as "not a collection", it read as "unknown type", and an - * unknown type takes the PLUGIN path, which every authorization gate is - * permissive toward by construction. So `PUT /meta/fields/showcase_task.title` - * answered 200 and persisted a row under `type='fields'` — a second namespace - * for the same item, the #4432 defect exactly — while `PUT /meta/field/...` - * answered 403 NOT_OVERRIDABLE. The plural URL was a door around the singular - * URL's lock. - * - * {@link canonicalMetaUrlType} reads a map DERIVED from - * `DEFAULT_METADATA_TYPE_REGISTRY` (unioned with every manifest spelling, so - * nothing that resolved before resolves differently), which is why a newly - * declared type cannot arrive unmapped. Adding the four missing keys to the - * manifest map by hand would have fixed only today's four — and would have - * advertised a `fields: [...]` stack collection that does not exist. - * - * ⛔ Do not "fix" a future instance of this by teaching a predicate one layer - * down (`isNestedArtifactField` and friends) to accept a plural. That is the - * spelling-tolerant lookup this comment has rejected since #4432, and it would - * still persist the row under the plural `type`. - */ -export function canonicalMetaType(type: string): string { - return canonicalMetaUrlType(type); -} - -/** - * {@link canonicalMetaType} applied to a `{ type }` request, without mutating the - * caller's object — and the one checkpoint that REFUSES a spelling it cannot - * honour instead of forwarding it to the plugin path (#7894). - * - * Applied here rather than inside {@link canonicalMetaType} on purpose: this is - * the request boundary (all six `/meta` entry points funnel through it), while - * `canonicalMetaType` is also called on read EXITS by `governServedItem` and - * `stripServedSystemColumns`, where the type is already canonical and a throw - * would be a bug rather than a refusal. - * - * The refusal is deliberately narrow: {@link metaUrlSpellingRefusal} returns a - * verdict only for a spelling whose singular is a type the platform itself - * DECLARES, so a plugin-registered runtime kind can never trip it. See that - * function for why the rule is static rather than a live-registry lookup — - * and (#8424) for why this boundary consumes the composed VERDICT rather than - * the predicate parts: the spelling contract stays whole at its producer. - * - * ## [#8421] The SECOND verdict is not here - * - * This refusal is silent for a name that is not a plural of ANYTHING - * (`fieldz`), because such a name reaches for no declared type. That residue - * was closed by the maintainer's ruling of 2026-08-14 (「同意」), joint with - * #8586 — but not in this function, because the verdict that closes it cannot - * be answered from the request alone: it has to know whether the namespace it - * would create already exists. It lives on the mint door itself, as - * {@link ObjectStackProtocolImplementation.refuseUnmintableMetaType}, which - * carries the scoping argument and the measurements behind it. - */ -function canonicalizeMetaRequestType(request: T): T { - const refusal = metaUrlSpellingRefusal(request.type); - if (refusal) { - const err = new Error( - `'${request.type}' is not a recognised spelling of metadata type ` - + `'${refusal.declared}'. Address it as '${refusal.declared}' or '${refusal.hint}'. ` - + `Refused rather than treated as a plugin-registered type, because forwarding an unrecognised ` - + `spelling of a declared type would create a second namespace under type='${request.type}'.`, - ); - (err as any).code = 'INVALID_REQUEST'; - (err as any).status = 400; - throw err; - } - const type = canonicalMetaType(request.type); - return type === request.type ? request : { ...request, type }; -} - -/** - * The last thing every `/meta` read does to an OBJECT document before it leaves - * this service: make the field metadata it reports agree with what the engine - * enforces on the write path — in BOTH of the ways it used to disagree. - * - * The mismatch this closes is structural, not incidental. A `/meta` object read - * resolves through `sys_metadata` overlay → MetadataService → SchemaRegistry, - * and only the last of those three has been through `applySystemFields`, so the - * answer a caller got depended on which link produced it — with nothing in the - * response saying which one had. Two halves, filed and ruled separately: - * - * - **[#4513] the VALUE half.** The two stored layers answered with whatever - * the artifact/overlay body happened to declare, while `ObjectQL.update` was - * stripping caller writes to the audit family off the registry's - * post-injection schema. `created_at` read `readonly: false` and wrote as - * read-only, on the same field, at the same moment, from the one face a - * client can actually see (#4447 fixed the write half; this is the read - * half). {@link applyAuditFieldGovernance} normalizes a DECLARED audit field. - * - **[#6562] the PRESENCE half.** The stored layers reported the platform's - * own injected columns — `created_at`, `owner_id`, `organization_id`, - * `owning_business_unit_id`, … — as simply ABSENT, so an author reading an - * overlay-backed object reasonably concluded the columns do not exist, while - * every one of them is real in the database, filterable, orderable and - * enforced read-only on write. Maintainer ruling (2026-08-08), Option B: the - * read serves the EFFECTIVE runtime schema and the overlay-backed minority - * converges on the registry-backed majority. - * {@link applyInjectedSystemColumns} adds an UNDECLARED injected column. - * - * The two are composed rather than folded, because they do different things to - * different fields: governance rewrites what the author declared, injection only - * ever adds what nobody declared. Both return their input by reference when - * nothing was needed, so the registry-sourced path (injected AND governed at - * registration) and every non-object type pay a comparison and no copy. - * - * Applied per EXIT rather than inside `decorateMetadataItem`: decoration is a - * diagnostics concern whose output `stripReadDecorations` deliberately removes - * again on write, and neither of these is that — they are what the document - * means. The read exits are also the ONLY place injection may happen (ruling - * constraint 1): `getMetaItemLayered` calls this on `effective` and never on - * `overlay`, so Studio's "what you customised" diff keeps showing the row the - * author actually stored. - * - * ⛔ The write path owes this function a counterpart. See - * {@link stripServedSystemColumns} — without it the standard Studio GET → edit → - * PUT round-trip would persist the injected columns into `sys_metadata`, and the - * #4326 byte-identical invariant would break the day this shipped. - */ -export function governServedItem(type: string, item: T): T { - if (canonicalMetaType(type) !== 'object') return item; - return applyInjectedSystemColumns(applyAuditFieldGovernance(item)); -} - -/** - * [#6562] The write-path counterpart of {@link governServedItem}'s injection - * half: take the injected-but-undeclared system columns back off a body on its - * way IN, so a served document handed straight back still persists byte-identical. - * - * Exactly the shape, and exactly the reason, of the `stripReadDecorations` call - * beside it in `saveMetaItem` (#4326) — the write path persists the request body - * verbatim by design for every type but `view` (ADR-0005 appendix (c), the - * "Addendum — 2026-05-16 (c)"), so anything the READ adds must come - * off again on the way in or it is baked into `sys_metadata.metadata`, into its - * checksum, and into every history diff. Kept a SEPARATE strip from that one - * rather than folded into `METADATA_READ_DECORATIONS`, because the two lists are - * different in kind: a read decoration is derived diagnostics no schema accepts, - * whereas an injected column is a real, spec-valid field declaration an author - * may legitimately write — so this strip removes only a field byte-identical to - * the platform's own definition, and a declared `owner_id` carrying the author's - * own label survives untouched. - */ -export function stripServedSystemColumns(type: string, item: T): T { - return canonicalMetaType(type) === 'object' ? stripInjectedSystemColumns(item) : item; -} - -/** - * [#7557, #24097] The ONE refusal for an object whose owning package is - * disabled, raised by every API reader of that object: the data plane - * ({@link ObjectStackProtocolImplementation.assertObjectRegistered}) and the - * by-name `/meta` reads (`getMetaItem`, `getMetaItemLayered`). One constructor, - * so the two halves of the answer cannot drift apart in code, status or text. - */ -export function objectPackageDisabledError(object: string): Error { - const disabled: any = new Error( - `Object '${object}' belongs to a disabled package and is not being served. ` - + 'Re-enable the package to restore access.', - ); - disabled.code = 'OBJECT_PACKAGE_DISABLED'; - disabled.status = 404; - disabled.object = object; - return disabled; -} - -/** - * [#7557, #24097] Whether `registry` says `name` is an object of a disabled - * package. Optional-called: registry doubles across the test suites implement - * `isPackageDisabled` but not this, and a host whose registry cannot answer - * must not have every read refused. - */ -export function isObjectOfDisabledPackage(registry: any, name: string): boolean { - return typeof registry?.isObjectPackageDisabled === 'function' - && registry.isObjectPackageDisabled(name) === true; -} - -/** - * [#7557, #24210] Whether `item` belongs to a disabled package: its - * `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a - * non-object item, asked by the list read ({@link - * ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and - * the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem}, - * which answers as for a name with nothing behind it), so the two cannot - * disagree about which items a disable hides. Optional-called, as {@link - * isObjectOfDisabledPackage} is: a host whose registry cannot answer has no - * item hidden. - */ -export function isItemOfDisabledPackage(registry: any, item: unknown): boolean { - return typeof registry?.isPackageDisabled === 'function' - && registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true; -} - -/** - * [#28877] Whether a disable withholds the `action` item `item` from the SERVED - * reads: the registry's `isActionWithheldByDisabledPackage`, the ONE predicate - * its served object fold (#24400's bound-action attach) asks — its own package - * disabled, its `objectName` object in a disabled package, or it launches into - * one (a `type: 'flow'` target only disabled packages ship, or a param whose - * `objectOverride` object belongs to a disabled package). Asked by the effective served list ({@link - * ObjectStackProtocolImplementation.getMetaItems}) and the served by-name read - * of an `action`, so `GET /meta/action` and the console's object read withhold - * the same actions. The execution face and the per-package layer (the package - * export) do not ask it: they keep what each package ships. Optional-called, - * as {@link isItemOfDisabledPackage} is: a registry that cannot answer hides - * nothing more than that predicate does. - */ -export function isActionWithheldByDisabledPackage(registry: any, item: unknown): boolean { - return typeof registry?.isActionWithheldByDisabledPackage === 'function' - && registry.isActionWithheldByDisabledPackage(item) === true; -} - -/** - * The strict draft read's `404 NO_DRAFT`: no pending draft at this address - * (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that - * finds no draft row and the read of a disabled package's draft, so the two - * answers are byte-identical. - */ -export function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } { - const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 }; - err.code = 'NO_DRAFT'; - err.status = 404; - return err; -} - -/** - * [#17501] Does a converted JSON Schema carry any authorable surface at all? - * - * `z.toJSONSchema()` answers a `ZodPipe` in its default `'output'` mode with a - * document that is syntactically a JSON Schema and semantically nothing: - * `{"$schema": "..."}` and not one property. It is truthy, so a `?? fallback` - * downstream never fires, and the endpoint serves a document that reads as - * "this type declares no constraints" when the truth is "this derivation could - * not see the type at all". - * - * A derivation is DEGENERATE when it names no property, offers no union arm, - * points at no `$ref` and opens no `additionalProperties` object — i.e. there - * is nothing an author or a form renderer could address. Every one of those - * four is a real authorable surface, so any one of them present means the - * derivation worked. - */ -function isDegenerateDerivation(json: Record | undefined): boolean { - if (!json || typeof json !== 'object') return true; - const properties = json.properties; - if (properties && typeof properties === 'object' && Object.keys(properties).length > 0) return false; - for (const arm of ['anyOf', 'oneOf', 'allOf'] as const) { - const value = json[arm]; - if (Array.isArray(value) && value.length > 0) return false; - } - if (typeof json.$ref === 'string') return false; - const additional = json.additionalProperties; - if (additional && typeof additional === 'object') return false; - return true; -} - -/** - * [#17501] One-shot per type: both derivations of this type's schema came back - * empty. Keyed by type name, not by schema reference, because the message names - * the type and repeating it per request is noise. - */ -const _warnedDegenerateDerivation = new Set(); - -/** - * Convert a Zod schema to a JSON Schema, returning `undefined` if no usable - * derivation exists — which is what lets the caller's `?? HAND_CRAFTED_SCHEMAS` - * arm fire. Cached per schema reference. - * - * ## [#17501] Why this tries TWICE, and why only sometimes - * - * `/meta/types` served `action` as `{"$schema": "..."}` — an empty schema for a - * type that accepts 48 keys — because `ActionSchema` is - * `lazySchema(() => actionObject().refine(...))`, a `ZodPipe`, and the OUTPUT - * derivation of a pipe carries no properties. The hand-crafted fallback - * declared for exactly this case never fired: conversion did not throw, it - * SUCCEEDED and returned a truthy husk. - * - * The obvious repair — derive everything with `io: 'input'`, the authoring - * shape a property panel actually wants, which `reference-sites.ts` already - * passes — was measured across the whole served surface and REFUSED. It is not - * a repair with a blast radius of one: 24 of the 26 types that carry a Zod - * schema answer differently under `input`, and the direction is a WEAKENING of - * a published contract — `required` entries 1132 to 867, `additionalProperties: - * false` 663 to 637. Serving the authoring shape for every type is a defensible - * contract change, but it is a `packages/spec` question with its own review, - * not a rider on this bug fix. (The card's own reading that thirteen types - * "answer identically" is a TOP-LEVEL PROPERTY COUNT, which cannot see either - * keyword; the counts reproduce exactly and the inference from them does not.) - * - * So the output derivation stays the served default and the authoring - * derivation is a RETRY, reached only when the default degenerates. Measured on - * the full registry: exactly one type — `action` — takes the retry arm, and the - * other 25 stay byte-for-byte identical. `protocol.meta-types-degenerate-derivation.test.ts` - * pins that count, which is the assertion that fails if someone later widens - * this to every type. - * - * ## Absence is deliberately NOT degeneracy - * - * A type with no Zod schema at all (`external_catalog`) never reaches here — - * the caller's `zodSchema ? ... : undefined` guard sees to that — and it is a - * different, honest shape: nothing is advertised, so nothing lies. This - * function judges only a derivation that RAN and came back empty. Widening it - * to cover missing schemas would change what absence means at the endpoint and - * is out of scope. - * - * ## A throwing conversion keeps its old path exactly - * - * If the output derivation THROWS, that is the case `HAND_CRAFTED_SCHEMAS` was - * documented for and it returns `undefined` as it always did — no retry. Not - * one type's conversion throws today, so retrying there would move no payload - * while widening the change past the ruling. - * - * ## [#19295] Why both derivations carry the erased-authoring `override` - * - * The degeneracy above is a WHOLE-TYPE husk. The same erasure also happens one - * level down, per MEMBER: a predicate slot's string arm is a `ZodPipe`, so the - * output derivation describes the transform's result and the arm itself is - * served as `{}` — indistinguishable from a member that admits anything. - * {@link markErasedAuthoringInput} annotates exactly those arms; it adds a - * vendor keyword and changes no keyword zod emitted, so what each document - * ACCEPTS is untouched and the refused `io: 'input'` widening stays refused. - * - * It is passed to BOTH calls on purpose. On the authoring retry a pipe derives - * from its input side, nothing is erased, and the hook marks nothing — so a - * type served from the retry (`action`) carries a real authoring arm instead - * of a marked husk, which is the honest answer rather than a gap. - */ -const _jsonSchemaCache = new WeakMap | null>(); -function toJsonSchemaSafe(schema: z.ZodTypeAny, typeLabel?: string): Record | undefined { - const cached = _jsonSchemaCache.get(schema); - if (cached !== undefined) return cached ?? undefined; - - let output: Record; - try { - output = z.toJSONSchema(schema, { - unrepresentable: 'any', - override: markErasedAuthoringInput, - }) as Record; - } catch { - // Conversion failed outright — the original hand-crafted-fallback case. - _jsonSchemaCache.set(schema, null); - return undefined; - } - - if (!isDegenerateDerivation(output)) { - const authorable = stripUnauthorableProperties(output); - _jsonSchemaCache.set(schema, authorable); - return authorable; - } - - // The default derivation produced a husk. Retry in the authoring shape - // before giving up — for a `ZodPipe` this is the derivation that can see - // the object at all. - try { - const authoring = z.toJSONSchema(schema, { - unrepresentable: 'any', - io: 'input', - override: markErasedAuthoringInput, - }) as Record; - if (!isDegenerateDerivation(authoring)) { - const authorable = stripUnauthorableProperties(authoring); - _jsonSchemaCache.set(schema, authorable); - return authorable; - } - } catch { - // Fall through to the loud arm below. - } - - // Both derivations are unusable. Say so once, then hand the caller - // `undefined` so its `?? HAND_CRAFTED_SCHEMAS` arm decides. - if (typeLabel && !_warnedDegenerateDerivation.has(typeLabel)) { - _warnedDegenerateDerivation.add(typeLabel); - console.warn( - `[Protocol] /meta/types: both the output and the authoring derivations of the ` - + `"${typeLabel}" schema came back empty, so no schema is derived from it — a property ` - + `panel built for this type will show no fields. Fix the schema so it derives, or ` - + `declare a HAND_CRAFTED_SCHEMAS entry for "${typeLabel}".`, - ); - } - _jsonSchemaCache.set(schema, null); - return undefined; -} - -/** - * Hand-crafted fallback JSON Schemas for metadata types whose Zod schema - * cannot be safely converted via `z.toJSONSchema()` (e.g. due to recursive - * references or non-representable constructs like `z.lazy()` chains). - * - * These mirror the shape consumed by the corresponding `*.form.ts` layouts, - * so the SchemaForm renderer can still produce a real form (instead of - * falling back to the raw JSON editor). All fields use lenient types - * (`string | object | array`) because the widget hint in the form layout - * is what actually drives the UI control selection — the JSON Schema is - * only used to (a) seed defaults and (b) report which property names exist. - */ -const HAND_CRAFTED_SCHEMAS: Record> = { - object: { - type: 'object', - properties: { - name: { type: 'string' }, - label: { type: 'string' }, - pluralLabel: { type: 'string' }, - icon: { type: 'string' }, - description: { type: 'string' }, - isSystem: { type: 'boolean', default: false }, - datasource: { type: 'string' }, - fields: { - // Canonical Object.fields is a name-keyed map - // (Record) — insertion order is - // display order. The SchemaForm engine recognises - // `additionalProperties` as a Record and dispatches to - // the `record` form-field renderer (ADR-0007). The form - // layout in `object.form.ts` declares `type: 'record'` - // so the inner `additionalProperties` schema is used to - // shape each value. - type: 'object', - default: {}, - additionalProperties: { - type: 'object', - properties: { - name: { type: 'string' }, - label: { type: 'string' }, - type: { type: 'string' }, - required: { type: 'boolean', default: false }, - unique: { type: 'boolean', default: false }, - defaultValue: {}, - description: { type: 'string' }, - }, - required: ['type'], - }, - }, - capabilities: { type: 'object', additionalProperties: true }, - }, - required: ['name'], - additionalProperties: true, - }, - action: { - type: 'object', - properties: { - name: { type: 'string' }, - label: { type: 'string' }, - objectName: { type: 'string' }, - icon: { type: 'string' }, - type: { type: 'string', enum: ['url', 'flow', 'api', 'script'] }, - variant: { type: 'string', enum: ['primary', 'secondary', 'danger', 'ghost', 'outline'] }, - target: { type: 'string' }, - method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, - body: { - type: 'array', - default: [], - items: { - type: 'object', - properties: { - line: { type: 'string' }, - }, - }, - }, - params: { - type: 'array', - default: [], - items: { - type: 'object', - properties: { - name: { type: 'string' }, - label: { type: 'string' }, - type: { type: 'string' }, - required: { type: 'boolean', default: false }, - }, - required: ['name'], - }, - }, - confirmText: { type: 'string' }, - successMessage: { type: 'string' }, - refreshAfter: { type: 'boolean', default: true }, - locations: { - type: 'array', - default: [], - items: { - type: 'object', - properties: { - location: { type: 'string' }, - }, - }, - }, - component: { type: 'string' }, - visible: { type: 'string' }, - disabled: { type: 'string' }, - // No `shortcut` / `bulkEnabled`: spec 17 retired both as - // `retiredKey()` tombstones, so authoring either is a hard parse - // rejection. This schema is what the Studio designer renders its - // fallback form from, so leaving them here handed authors two - // inputs that could only ever produce an unsaveable draft - // (objectui#3145 removed the matching dedicated controls). - // `bulkEnabled`'s replacement is the list view's `bulkActions` / - // `bulkActionDefs`; `shortcut` has none. - aiExposed: { type: 'boolean', default: false }, - recordIdParam: { type: 'string' }, - recordIdField: { type: 'string' }, - bodyShape: { type: 'string', enum: ['flat', 'nested'] }, - }, - required: ['name', 'label', 'type'], - additionalProperties: true, - }, - // ADR-0088 (#4509): the `validation` kind is retired, so its hand-crafted - // form goes with it. Rules are authored inside `object.validations[]` and - // edited on the object; there is no standalone validation editor to render - // a schema for. (The form was flat by necessity — ValidationRuleSchema is a - // 6-variant discriminated union the generic SchemaForm treats as opaque — - // and it had no field for the object being validated, which is precisely - // the gap that retired the kind: a rule saved here bound to nothing.) -}; - -/** - * Zod schemas used to validate overlay items before they are persisted into - * `sys_metadata` by {@link ObjectStackProtocolImplementation.saveMetaItem}. - * - * Single source of truth: the spec-side {@link getMetadataTypeSchema} - * registry (`@objectstack/spec/kernel/metadata-type-schemas`). Every - * metadata type whose payload should round-trip through Studio's - * generic editor maps to its canonical Zod schema there; this function - * is a plural→singular adapter on top of it. - * - * Validation policy: - * - `safeParse` is used so we can craft a 422 with structured `issues`. - * - [#20051] What is persisted depends on the type — ADR-0005 appendix (c), - * the "Addendum — 2026-05-16 (c): spec validation on overlay save": - * - `view` stores the parsed value of every key its body carried: - * undeclared keys are dropped, schema defaults are not materialised - * (ADR-0087's `storable` rule, the one flows follow). Every key the - * console reads back off a stored row is declared on the contract - * (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), so the parse keeps it. See - * {@link projectStorableViewBody}. - * - Every other type stores its original payload verbatim, with two - * normalizations grafted back from `parsed.data` so a save stops - * minting legacy-alias rows — filter `operator` spellings - * ({@link graftNormalizedOperators}) and the form `groups` → `sections` - * move ({@link graftFoldedFormSections}). [#20101] A page that omits - * `type` is stored with the default `PageSchema` declares for it — see - * {@link withDeclaredPageTypeDefault}. - * - Types without a registered schema (the wiring-layer types - * `function`/`service`/`router`, and any plugin types that have not - * yet called `registerMetadataTypeSchema()`) fall through unvalidated. - */ -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; -} - -/** - * [ADR-0131 D6, ADR-0010 Phase 4.3] The schema the save door judges `key` by on - * an item of `type` — read off {@link resolveOverlaySchema}, the door's own - * resolver, so no list of which types declare which key is kept anywhere — or - * `undefined` when that schema declares no such key (or the type has none). - * - * Read structurally: the object shapes the schema parses to, through the - * wrappers the registered types use (lazy, pipe, union, intersection and the - * optional family). A union declares the key when any member does. A schema - * this walk cannot read answers `undefined`, which leaves the key to the save - * door's own verdict — the direction that changes no refusal. - * - * The unmanaged import asks it for `protection`: only a type that declares the - * block has one to strip (`stripUnmanagedImportLock`); on any other type the - * save door judges the block exactly as it did before. - */ -export function overlaySchemaDeclaredKey(type: string, key: string): z.ZodTypeAny | undefined { - return declaredKeySchema(resolveOverlaySchema(type, undefined), key, 0); -} - -function declaredKeySchema(schema: unknown, key: string, depth: number): z.ZodTypeAny | undefined { - if (!schema || depth > 12) return undefined; - const def = (schema as { _zod?: { def?: Record } })._zod?.def; - switch (def?.type) { - case 'object': { - const shape = (schema as { shape?: Record }).shape ?? def.shape ?? {}; - return Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] as z.ZodTypeAny : undefined; - } - case 'lazy': - return declaredKeySchema(def.getter?.(), key, depth + 1); - case 'pipe': - return declaredKeySchema(def.in, key, depth + 1) ?? declaredKeySchema(def.out, key, depth + 1); - case 'intersection': - return declaredKeySchema(def.left, key, depth + 1) ?? declaredKeySchema(def.right, key, depth + 1); - case 'union': - for (const option of (def.options ?? []) as unknown[]) { - const found = declaredKeySchema(option, key, depth + 1); - if (found) return found; - } - return undefined; - case 'optional': - case 'nullable': - case 'default': - case 'prefault': - case 'readonly': - case 'nonoptional': - case 'catch': - return declaredKeySchema(def.innerType, key, depth + 1); - default: - return undefined; - } -} - -/** - * [#21658, #21689] The save door's refusal of a `hook` that carries no `body`: - * such a hook can never run once this door has stored it. One predicate, two - * shapes it meets, one envelope: a hook whose `handler` names a function, and a - * hook with neither field. - * - * Why it can never run. A hook this door stores ships with no code package: - * the runtime binds every stored hook under the synthetic owner - * `metadata-service` (`ObjectQLPlugin`'s authored hook re-sync), with no - * `functions` map, and no package of that name registers functions. So a - * `body`, which is stored with the hook, is the only code it can run. - * - * - A `handler` name resolves inside the hook's own package only (the - * maintainer's ruling on #21604, letter B; the binder's `resolveHandler` in - * `@objectstack/objectql`'s `hook-binder.ts`), so it has nothing to resolve - * against, and the binder refuses the hook at registration - * (`INVALID_REFERENCE` / 400, logged at `error`). - * - A hook with neither field has nothing to bind at all, and the binder - * skips it at every re-sync (`skipping hook with unresolved handler`, - * logged at `warn`). - * - * Either way the door would already have answered success, and the hook would - * be served by name and never run. Refusing it here says so to the author, - * before anything is stored. - * - * The predicate is the binder's own body-first test, the judgement - * install-local's `collectHooksWithoutBody` makes on its own door (#21585): a - * `body` object is bound through the body runner and the `handler` is never - * consulted, so a hook carrying BOTH a `body` and a `handler` saves (its body - * runs), and every hook without a `body` object is refused, whatever its - * `handler` holds. Asked after the type schema has accepted the body, so - * `body` here is either absent or a declared hook body, and a malformed `body` - * gets the schema's own located `422` instead of this refusal's "give it a - * body". - * - * ⛔ Not a `HookSchema` rule: a build artifact legitimately carries the string - * form (`objectstack build` lowers an inline function to the hook's name and - * ships the function in the artifact's runtime module), and the artifact and - * boot doors never reach `saveMetaItem`. This is the runtime-authoring door's - * rule only. - * - * Every writer through this door is judged: the REST and dispatcher saves, in - * draft and in publish mode, and the two server-stated re-savers - * (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as - * the row's failure. A row stored before this rule keeps its bytes. - * - * `VALIDATION_ERROR` / 400, the envelope of the name check the door runs on - * every body (`savedItemNameRefusal`). The message names the hook (and the - * function, when its `handler` names one), prescribes the `body` first, and - * only then explains: a 4xx message crosses the REST boundary bounded at 500 - * characters with its TAIL truncated, and the whole sentence stays under that - * bound for any hook and function name shorter than about 65 characters each. - * Runtime words carry no tracker number. - */ -export function runtimeHookWithoutBodyRefusal( - singularType: string, - item: unknown, - saveName: string, -): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined { - if (singularType !== 'hook') return undefined; - if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; - const hook = item as { handler?: unknown; body?: unknown }; - if (hook.body && typeof hook.body === 'object') return undefined; - const prescription = 'Give it a `body` (sandboxed JS, `{ language: \'js\', source }`, or an expression), ' - + 'which is stored with the hook. A hook saved through the metadata API ships with no code package, so '; - const err = new Error( - typeof hook.handler === 'string' && hook.handler !== '' - ? `Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, ` - + `so it can never run. ${prescription}it holds no functions, and a \`handler\` name resolves only inside ` - + "the hook's own package." - : `Invalid hook: '${saveName}' carries no \`body\`, so it has nothing to run. ${prescription}` - + 'its `body` is the only code it can run.', - ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - return err; -} - -/** - * [ADR-0086 D3] The refusal of a permission set whose body `packageId` - * disagrees with the package binding of the row a write door writes it into. - * - * D3 declares the key as "the owning package for a package-shipped set - * (absent ⇒ env-authored)", and every reader of a set's owner reads the - * binding: the row's `package_id`, served as `_packageId`. A body that - * declared another owner was accepted and its declaration silently - * overridden, so an author (an AI build above all) was never told the owner it - * wrote is not the owner it got. Refused instead, at every door a body reaches - * `active` through: the save ({@link ObjectStackProtocolImplementation.saveMetaItem}, - * draft and publish mode), the restore of a stored version - * ({@link ObjectStackProtocolImplementation.rollbackMetaItem} and - * `revertCommit`'s restore limb, through `restoredBodyWriter`) and the draft - * promotion that the per-item publish and - * {@link ObjectStackProtocolImplementation.publishPackageDrafts} share — the - * three doors `savedItemNameRefusal` judges a body's `name` at. - * - * What disagrees, and the words, are the one judge every door of this rule - * calls: `judgePermissionSetOwnerBinding` (`@objectstack/spec/security`, whose - * header holds the verdict table), which the stack schema (the build door) and - * `ObjectQL.registerApp` (the registration door) call too. This function is the - * write doors' envelope around it, and its words for these three doors are byte - * for byte the words this function built before the judge moved. `binding` is - * the RESOLVED binding of the row the write lands in, never the request's - * spelling: a draft save that names no package lands in the package of the - * item's active row (#11087), a direct save that names none lands in the row - * its address resolves, and a restore writes into the item's active row, whose - * binding `put` keeps. - * - * `VALIDATION_ERROR` / 400, the envelope of the door's other body-against- - * address refusal: `savedItemNameRefusal`, which judges the body's `name` - * against the name the row is written under, at the same doors. No code is - * minted. `issues[]` locates the key, the shape the Studio designer keys on. - * - * Every writer through the save door is judged, the two server-stated - * re-savers included (`migrateStoredMetadata`, which records the refusal as - * the row's failure, and `duplicatePackage`, which rewrites a body naming its - * source package to the target first). A row stored before this rule keeps its - * bytes and keeps reading by its binding; it is refused on its next write. - */ -export function permissionSetOwnerBindingRefusal( - singularType: string, - item: unknown, - name: string, - binding: string | null | undefined, - door: 'save' | 'restore' | 'publish', -): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined { - if (singularType !== 'permission') return undefined; - const refusal = judgePermissionSetOwnerBinding(item, name, binding, door); - if (!refusal) return undefined; - const err = new Error(refusal.message) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.issues = [{ path: 'packageId', message: refusal.issueMessage, code: 'custom' }]; - return err; -} - -/** The author-facing lock block (ADR-0010 Phase 4.3), as the spec mounts it on a metadata item. */ -const PROTECTION_BLOCK_KEY = 'protection'; - -/** - * [ADR-0010 §3.5 and Phase 4.3] The save door's refusal of a `protection` - * block — the package author's lock declaration, which this door cannot - * honour. - * - * ADR-0010 states one door for the block. Phase 4.3 calls it the public - * author surface "that translates into the private `_lock` envelope at load - * time": the loader "calls `applyProtection(item, { packageId })` to translate - * the block … and strips the public `protection` block so it never leaks into - * the overlay row". An item this door writes is authored in the environment, - * and §3.5 states what a lock means for one: "Tenant `org` artifacts ignore - * L3 / L4 entirely (the tenant owns them in full, can also delete them)". So - * the block has no translation here, and translating it anyway would open a - * lock door ADR-0010 does not state. Stored verbatim, which is what this door - * did before, the block was served back as `protection.lock: 'full'` while the - * item read `lock: 'none'` and every edit and delete succeeded: a body telling - * its reader the item is protected when it is not. - * - * Asked only on a type whose schema declares the block, read structurally by - * {@link overlaySchemaDeclaredKey} (the unmanaged import asks the same - * question). On any other type the schema's own verdict stands: an - * unrecognized key, refused by the parse. - * - * Every AUTHORING write is refused: the REST and dispatcher saves, in draft - * and in publish mode, the domain doors that save through this one, and the - * unmanaged import, which strips a block it can parse before the body reaches - * this door (`stripUnmanagedImportLock`) and hands a malformed one on to be - * refused here. The server-stated rewrites of rows the store already holds — - * a `source`-stating re-save (`migrateStoredMetadata`, the flow credential - * move) and `duplicatePackage` — strip the block instead, through - * {@link withoutProtectionBlock}, exactly as they strip the derived provenance - * stamps (`tenantAuthoredWriteRefusal` draws the same line): a row stored - * before this rule may carry one, the block never bound, so removing it - * changes no verdict, and failing the rewrite would fail a stored migration or - * a credential move over a key that does nothing. A row nothing rewrites keeps - * its bytes. - * - * `VALIDATION_ERROR` / 400, the envelope of the door's other content refusals - * (`savedItemNameRefusal`, the hook refusal above). No code is minted. It is - * located twice: the message names the key, and `issues[]` carries it as a - * path, the shape the Studio designer keys on. Every present value is refused, - * a malformed block included, because the block is not accepted here at all: - * judging its value first would ask an author to repair a block they then have - * to delete. The message prescribes first and explains second, because a 4xx - * message crosses the REST boundary bounded at 500 characters with its TAIL - * truncated. - * - * ⛔ The prescription names the package loader's door, never a `_lock*` key: - * those are the runtime's private envelope, never authored - * (`shared/protection.zod.ts`). - */ -function protectionBlockRefusal( - type: string, - item: unknown, - saveName: string, -): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined { - if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; - if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return undefined; - if ((item as Record)[PROTECTION_BLOCK_KEY] === undefined) return undefined; - if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return undefined; - const err = new Error( - `Remove the \`protection\` block from ${type} '${saveName}' and send it again. Nothing was written. ` - + 'This door does not translate the block, so it would lock nothing: an item saved through the metadata API ' - + 'is authored in this system (ADR-0010 §3.5). To lock an item, declare `protection` on it in a code ' - + "package's source, where the package loader translates it into the item's lock (ADR-0010 Phase 4.3).", - ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.issues = [{ - path: PROTECTION_BLOCK_KEY, - message: "Not accepted through the metadata API: only a code package's loader translates `protection` into a lock.", - code: 'custom', - }]; - return err; -} - -/** - * [ADR-0010 §3.5] The server-stated rewrites' half of - * {@link protectionBlockRefusal}: the same body without its `protection` block, - * on a type whose schema declares one. On any other type the body passes as - * it is, for the parse to judge. - * - * Returns the SAME reference when there is nothing to strip, and a copy - * otherwise, so a caller's own body is never mutated. - */ -function withoutProtectionBlock(type: string, item: unknown): unknown { - if (!item || typeof item !== 'object' || Array.isArray(item)) return item; - if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return item; - if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return item; - const rest = { ...(item as Record) }; - delete rest[PROTECTION_BLOCK_KEY]; - return rest; -} - -/** - * [#23165] The rows of an item a delete leaves stored at its address, in - * words, for the delete receipt: its rows bound to other packages (sorted, as - * {@link ObjectStackProtocolImplementation.packagelessAddress} answers them, - * at most five named) and its package-less row. The receipt names them - * instead of claiming the item gone or back at its artifact default. - */ -export function remainingRowsPhrase(packageIds: readonly string[], unbound: boolean, noun: 'row' | 'draft'): string { - const parts: string[] = []; - if (packageIds.length > 0) { - const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); - const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; - parts.push(packageIds.length === 1 - ? `its ${noun} bound to package ${shown}` - : `its ${noun}s bound to packages ${shown}${more}`); - } - if (unbound) parts.push(`its package-less ${noun}`); - const count = packageIds.length + (unbound ? 1 : 0); - return `${parts.join(' and ')} ${count === 1 ? 'remains' : 'remain'}`; -} - -/** - * [#23495, ADR-0048] The `404 NO_DRAFT` of a per-item publish that names no - * package, of an item bound to package `P` that holds no draft at `P`'s - * address ({@link ObjectStackProtocolImplementation.packagelessDraftAddress}) - * while drafts bound to other packages are stored. The code and status are the - * promotion's own, the ones the strict draft read naming no package answers - * in the same state; the sentence names the drafts that remain and the - * request that reaches them, prescription first (a 4xx message crosses the - * REST boundary bounded at 500 characters). - */ -function packagelessPublishNoDraftRefusal( - type: string, - name: string, - packageId: string, - otherPackageIds: readonly string[], -): Error & { code: 'NO_DRAFT'; status: 404 } { - const err = new Error( - 'Name the package of the draft to publish, then send it again: on the REST door add ?package= with the ' - + `package id, in process pass packageId. Nothing was published. ${type} '${name}' has no pending draft ` - + `bound to package '${packageId}', the package its published rows are bound to and the draft a publish ` - + `that names no package promotes, and ${remainingRowsPhrase(otherPackageIds, false, 'draft')}.`, - ) as Error & { code: 'NO_DRAFT'; status: 404 }; - err.code = 'NO_DRAFT'; - err.status = 404; - return err; -} - -/** - * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape - * Studio's designer keys on to highlight the offending form control. - * - * `code` is zod's OWN issue code, passed through verbatim. That is deliberately - * NOT the ADR-0114 `fields[]` catalog the data surface speaks: this envelope is - * a metadata-authoring diagnostic, its consumers already read raw zod codes, and - * aligning the two vocabularies is a separate decision (#5364). - */ -export interface MetadataIssueEntry { - path: string; - message: string; - code: string | undefined; -} - -/** - * [#24253] One field a `publishPackageDrafts` backfill will write: the entry as - * the request named it, plus the literal its draft declares. Built only by - * `planPublishBackfill`, so every value here was judged before the publish. - */ -export interface PublishBackfillEntry { - object: string; - field: string; - value: unknown; -} - -/** - * [#24253] The `backfillApplied` receipt of `publishPackageDrafts`, declared as - * `PublishPackageDraftsResponseSchema.backfillApplied` in `@objectstack/spec/api`. - * `updated` counts the rows this publish wrote, so a row that already held a - * value (a database column DEFAULT fills one on the SQL driver) is not counted. - */ -export type PublishBackfillReceipt = { - success: boolean; - fields: Array<{ object: string; field: string; updated: number; error?: string; code?: string }>; -}; - -/** [#24253] Rows read and written per round of one field's backfill. */ -export const PUBLISH_BACKFILL_PAGE_SIZE = 200; - -/** - * [#24253] The 400 a `publishPackageDrafts` backfill request is refused with: - * every entry that cannot be honoured, by path, in one answer, before anything - * is promoted or written. The headline names the first; `issues[]` names all. - */ -export function publishBackfillRefusal( - packageId: string, - issues: MetadataIssueEntry[], -): Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] } { - const first = issues[0]!; - const err = new Error( - `Nothing was published: the backfill of package '${packageId}' cannot be honoured ` - + `(${issues.length} ${issues.length === 1 ? 'problem' : 'problems'}; first at ${first.path}: ${first.message}) ` - + 'Fix or drop the named entries and publish again.', - ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.issues = issues; - return err; -} - -/** - * [#24253] The shape half of the backfill judgement: an array of - * `{ object, field }` pairs, each named once, and the caller's context to write - * as. Read before any server state, so a malformed request is refused the same - * way whatever the package holds. Absent is nothing asked; so is `[]`. - */ -function readPublishBackfillRequest( - packageId: string, - backfill: unknown, - context: unknown, -): Array<{ object: string; field: string }> { - if (backfill === undefined) return []; - const issues: MetadataIssueEntry[] = []; - if (!Array.isArray(backfill)) { - throw publishBackfillRefusal(packageId, [{ - path: 'backfill', - message: 'Must be an array of { object, field } pairs naming the fields this publish adds.', - code: 'invalid_type', - }]); - } - const entries: Array<{ object: string; field: string }> = []; - const seen = new Set(); - backfill.forEach((entry: unknown, i: number) => { - const path = `backfill.${i}`; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - issues.push({ path, message: 'Must be an object { object, field }.', code: 'invalid_type' }); - return; - } - const extra = Object.keys(entry).filter((k) => k !== 'object' && k !== 'field'); - if (extra.length > 0) { - issues.push({ - path, - message: `Unrecognized ${extra.length === 1 ? 'key' : 'keys'} ${extra.map((k) => `'${k}'`).join(', ')}: ` - + 'an entry names an object and a field, and the value written is the field\'s own defaultValue.', - code: 'unrecognized_keys', - }); - return; - } - const { object, field } = entry as { object?: unknown; field?: unknown }; - if (typeof object !== 'string' || object.length === 0 || typeof field !== 'string' || field.length === 0) { - issues.push({ path, message: 'Both `object` and `field` must be non-empty strings.', code: 'invalid_type' }); - return; - } - const key = `${object}.${field}`; - if (seen.has(key)) { - issues.push({ path, message: `'${key}' is named more than once.`, code: 'custom' }); - return; - } - seen.add(key); - entries.push({ object, field }); - }); - if (entries.length > 0 && (!context || typeof context !== 'object')) { - issues.push({ - path: 'backfill', - message: 'A backfill writes as the caller, and no caller context reached this publish: ' - + 'pass the caller\'s execution context as `context`. It is never written as the system.', - code: 'custom', - }); - } - if (issues.length > 0) throw publishBackfillRefusal(packageId, issues); - return entries; -} - -/** - * How many levels of nested issues are expanded below a top-level issue, and - * how many equally-informative branches are emitted at one level. - * - * Both bounds — and the whole selection policy below — are the ones - * `formatZodError` landed for the CLI/spec side of this defect (#4971, - * `spec/src/shared/error-map.zod.ts`) and `zodIssuesToFields` landed for the - * REST wire (#5014, `rest/src/rest-server.ts`). This is the THIRD copy: spec - * exports only the STRING renderer and rest's version emits ADR-0114 - * `{field, code}` catalog entries, while this envelope is - * `{path, message, code}` with zod's raw code. The *verdict* must match all the - * same, or one mistake gets three different prescriptions depending on whether - * the author published from the terminal, POSTed to the data API, or saved from - * Studio (#5364). - * - * [#8783] The depth bound is named for what it bounds — nesting — and not for - * `invalid_union`, because it now bounds the container descent below as well. - * The spec pair renamed the same constant for the same reason when #8318 - * extracted it (`NESTED_EXPANSION_DEPTH_LIMIT` in - * `spec/src/shared/union-branch-policy.ts`); the value is the same 3, and it - * has to be, for the reason in the paragraph above. - */ -const NESTED_EXPANSION_DEPTH_LIMIT = 3; -const UNION_BRANCH_EMIT_LIMIT = 3; - -/** - * [#5389/#8783] The issue codes that hang their real diagnosis on - * `issue.issues` rather than on `invalid_union`'s `issue.errors`. - * - * Zod raises these when a CONTAINER's inner schema rejects a key or an element - * that cannot be addressed by a path segment: - * - * - `invalid_key` — `z.record(K, V)`'s **key** schema rejected a key, and - * `z.map(K, V)`'s key schema rejected a non-`PropertyKey` key; - * - `invalid_element` — `z.map(K, V)`'s **value** schema rejected the value - * under a non-`PropertyKey` key. - * - * In both cases the issue's own `message` is a bare wrapper (`"Invalid key in - * record"`) and everything the author needs sits one level down, exactly as - * `invalid_union` hides a branch's prescription in `errors` (#4971). Without - * the descent this envelope stopped at the wrapper while the other two walks - * descended, so the single most-authored metadata type's commonest authoring - * mistake — `ObjectSchema.fields` is a record whose KEY schema carries the - * snake_case rule (`spec/src/data/object.zod.ts`) — reached Studio as - * `{path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in - * record'}` with the sentence naming the rule dropped (#8783). - * - * ⚠️ These issues are NOT ranked, and that is the one structural difference - * from the union descent below. A union's branches are competing *candidates*, - * so they are selected between; a container has one inner schema, so every - * issue it produced is a true statement about the value and dropping any would - * be dropping a real diagnosis. Hence the plain loop rather than - * {@link selectUnionBranches}. - * - * ⛔ Deliberately a local declaration, not an import. `spec`'s - * `CONTAINER_ISSUE_CODES` is package-internal by decision (the #4001 export - * pitfall) and reaching for it would be #8660's option 2 — a public-surface - * decision this change does not make. `union-branch-policy.cross-package-parity.test.ts` - * §1 is the tripwire that keeps that decision explicit, and §5 there pins this - * set's *behaviour* against both spec walks. - */ -const CONTAINER_ISSUE_CODES: ReadonlySet = new Set(['invalid_key', 'invalid_element']); - -/** - * A zod issue, as much of it as the expansion reads. - * - * `errors` exists only on `invalid_union`: one issue list **per union branch**, - * with each branch's paths RELATIVE to the union issue's own path. Zod raises a - * single `invalid_union` issue whose own `message` is the literal - * `"Invalid input"`, so everything a failing branch has to say lives down there. - * - * `issues` is the container equivalent (#8783), carried by the - * {@link CONTAINER_ISSUE_CODES}: the one issue list the failing key/element - * schema produced, with paths relative to this issue's own — the same - * relationship `errors` has, spelled with a different property name and without - * the per-branch nesting, because a container has one inner schema rather than - * N alternatives. - */ -interface ZodIssueLike { - path?: unknown; - message?: unknown; - code?: unknown; - errors?: unknown; - issues?: unknown; -} - -/** A zod issue path, normalised to the array zod always produces. */ -function issuePathOf(issue: ZodIssueLike): Array { - return Array.isArray(issue?.path) ? (issue.path as Array) : []; -} - -/** - * True when a branch only complains that the value is the wrong *kind* at the - * branch root — `expected string, received object` for the string member of - * `z.union([z.string(), SomeObject])`. - * - * Such a branch carries no prescription: the author never intended it, and - * emitting it is the "N branches, N times the noise" failure. An empty branch - * (zod's "matched multiple" variant carries `errors: []`) counts as - * uninformative too — `every` on an empty list is `true`. - */ -function isKindMismatchOnly(issues: readonly ZodIssueLike[]): boolean { - return issues.every( - (issue) => - issuePathOf(issue).length === 0 - && (issue?.code === 'invalid_type' || issue?.code === 'invalid_value'), - ); -} - -/** True when a branch carries the #4001 campaign's unknown-key prescription. */ -function carriesUnknownKey(issues: readonly ZodIssueLike[]): boolean { - return issues.some((issue) => issue?.code === 'unrecognized_keys'); -} - -/** - * Pick the branch(es) of a failed union whose issues actually explain the - * failure. Ranking, in order (identical to `selectUnionBranches` in - * `spec/src/shared/error-map.zod.ts` and `rest/src/rest-server.ts`): - * - * 1. **Kind-mismatch-only branches are dropped entirely.** If *every* branch is - * one — a plain `z.union([z.string(), z.number()])` handed an object — - * nothing is selected and the union reports exactly what it always has. - * 2. **Fewest issues wins.** The branch the author was closest to hitting - * complains least, so "fewest" is what keeps ONE unknown key from arriving as - * N `issues[]` entries, one per branch. - * 3. **A branch carrying `unrecognized_keys` breaks a tie**, because that is - * where the curated prose lives. - * 4. Declaration order breaks what remains, so the envelope is deterministic. - * - * Branches that tie at the top are all emitted (capped): when two shapes explain - * the failure equally well, privileging the first by accident of declaration - * order would be a lie about which shape was expected. - */ -function selectUnionBranches( - branches: readonly (readonly ZodIssueLike[])[], -): readonly (readonly ZodIssueLike[])[] { - const informative = branches - .map((issues, index) => ({ issues, index })) - .filter((branch) => !isKindMismatchOnly(branch.issues)); - if (informative.length === 0) return []; - - const rank = (branch: { issues: readonly ZodIssueLike[] }): [number, number] => [ - branch.issues.length, - carriesUnknownKey(branch.issues) ? 0 : 1, - ]; - - const sorted = [...informative].sort((a, b) => { - const [aCount, aKeys] = rank(a); - const [bCount, bKeys] = rank(b); - return aCount - bCount || aKeys - bKeys || a.index - b.index; - }); - - const [bestCount, bestKeys] = rank(sorted[0]!); - return sorted - .filter((branch) => { - const [count, keys] = rank(branch); - return count === bestCount && keys === bestKeys; - }) - .slice(0, UNION_BRANCH_EMIT_LIMIT) - .map((branch) => branch.issues); -} - -/** - * One issue → its `issues[]` entries, appended to `out`. - * - * An ordinary issue is one entry. An `invalid_union` is its own entry (zod's - * bare `"Invalid input"`) FOLLOWED by the entries of the branches that explain - * it, with `path` resolved against the union's own — branch paths are RELATIVE - * to it, which is the trap #5014 paid for: a branch issue's `path` names a slot - * inside the union member, not inside the document. - * - * [#8783] A {@link CONTAINER_ISSUE_CODES} issue descends the same way and for - * the same reason, into `issue.issues` instead of `issue.errors`, with two - * differences: nothing is ranked (a container has one inner schema, so every - * issue it raised is true — see the note on the code set), and the descent is - * only taken when the issue's own code says the payload is a container's. That - * second half is what keeps this targeted: an `issues` array hanging off any - * other code is left alone, so the walk did not become "descend whatever - * nests". - * - * The wrapper's entry is kept rather than replaced — for the union and the - * container alike: it is the only entry naming the slot the client sent, - * existing consumers already read it, and when nothing below is informative it - * is still the whole answer. So the expansion is strictly ADDITIVE — no entry - * that shipped before this changed is gone or renumbered, only newly - * accompanied. Both spec walks were measured to do the same before #8783 copied - * them (`formatZodIssue` prints the wrapper line then the indented detail; - * `zodIssuesToFields` emits the `invalid_shape` wrapper then the detail entry). - * - * `seen` de-duplicates entries *within one top-level issue*: two branches that - * reject the same key with the same words say it once. Expanded entries — union - * and container heads alike — are exempt, since two same-path wrapper entries - * can head genuinely different sub-trees. - * - * Deliberate divergence from the spec-side renderer: where it prints a trailing - * "… and N more branches rejected this value", this emits nothing. That line is - * a rendering affordance for a terminal; an `issues[]` entry is a machine-read - * record that must name a slot and carry a code, and the omission note has - * neither. - */ -function collectMetadataIssues( - issue: ZodIssueLike, - parentPath: Array, - depth: number, - seen: Set, - out: MetadataIssueEntry[], -): void { - const path = [...parentPath, ...issuePathOf(issue)]; - const branches: readonly (readonly ZodIssueLike[])[] = - issue?.code === 'invalid_union' && Array.isArray(issue?.errors) - ? (issue.errors as unknown[]).filter( - (branch): branch is ZodIssueLike[] => Array.isArray(branch), - ) - : []; - const contained: readonly ZodIssueLike[] = - typeof issue?.code === 'string' && CONTAINER_ISSUE_CODES.has(issue.code) - && Array.isArray(issue?.issues) - ? (issue.issues as ZodIssueLike[]) - : []; - const expandable = (branches.length > 0 || contained.length > 0) - && depth < NESTED_EXPANSION_DEPTH_LIMIT; - - const entry: MetadataIssueEntry = { - path: path.join('.'), - message: String(issue?.message ?? 'Invalid value'), - code: issue?.code === undefined ? undefined : String(issue.code), - }; - - if (!expandable) { - const key = JSON.stringify([entry.path, entry.code, entry.message]); - if (seen.has(key)) return; - seen.add(key); - } - out.push(entry); - if (!expandable) return; - - if (branches.length > 0) { - for (const branch of selectUnionBranches(branches)) { - for (const nested of branch) { - collectMetadataIssues(nested, path, depth + 1, seen, out); - } - } - return; - } - - // Unranked, and every one of them: see {@link CONTAINER_ISSUE_CODES}. - for (const nested of contained) { - collectMetadataIssues(nested, path, depth + 1, seen, out); - } -} - -/** - * Zod issues → the `422 INVALID_METADATA` envelope's `issues[]`. - * - * A rejection behind a `z.union` is expanded (#5364): zod folds every branch of - * a failed union into ONE top-level issue whose message is the literal - * `"Invalid input"`, so mapping only top-level issues put - * `[{path: '', message: 'Invalid input', code: 'invalid_union'}]` on the wire — - * not one field name — while the branch that says WHICH key is wrong (the #4001 - * curated unknown-key prose, the legal enum of a mistyped discriminator) was - * produced and dropped at the `.map()`. - * - * That mattered most HERE of the four consumers of this defect: `ViewMetadataSchema` - * is itself a top-level union, so EVERY failed `view` save degraded to that one - * rootless line and Studio's form had nothing to highlight. The other three — - * `formatZodError` (#4971), `zodIssuesToFields` (#5014), the CLI's - * `formatZodErrors` (#5341) — lost prescriptions; this one lost field - * localisation itself. - * - * A rejection inside a CONTAINER is expanded for the same reason (#8783): zod - * hangs a `z.record`/`z.map` key or element rejection on - * {@link CONTAINER_ISSUE_CODES} with the bare wrapper `"Invalid key in record"` - * as its own message and the real rule one level down in `issue.issues`. Both - * spec walks have descended those since #5389 while this one stopped at the - * wrapper, so an author who typed `firstName` for a field key was told a key - * was invalid and never told what a valid one looks like — the declared - * snake_case prescription exists, is correct, and simply did not reach the - * author. - * - * Branch selection is described on {@link selectUnionBranches} and is identical - * to the other copies by construction; the container descent is described on - * {@link CONTAINER_ISSUE_CODES}. - */ -export function zodIssuesToMetadataIssues(issues: unknown): MetadataIssueEntry[] { - if (!Array.isArray(issues)) return []; - const out: MetadataIssueEntry[] = []; - for (const issue of issues) { - // A fresh `seen` per top-level issue: de-duplication is about one - // union's branches agreeing, never about two independent issues. - collectMetadataIssues(issue as ZodIssueLike, [], 0, new Set(), out); - } - return out; -} - -/** - * [#4435/#5138] The 404 a single-record operation answers when the id names no - * row — the repo's ONE `RECORD_NOT_FOUND` envelope. - * - * [#7867] The body moved to `@objectstack/core` - * (`utils/record-not-found.ts` — full provenance lives there); this is a - * re-export, so every existing importer of - * `@objectstack/metadata-protocol`'s `recordNotFoundError` is unchanged and - * the three producers still share one function object. - * - * ⛔ Do not re-declare it here. It moved because a THIRD producer needed it and - * could not reach this package: `ObjectQL.update()`/`delete()`'s by-id gate - * lives in `packages/objectql`, whose `/core` entry closure is forbidden by - * ADR-0076 D2's boundary ratchet from importing `@objectstack/metadata-protocol` - * at all. A local copy here would be the second spelling #5138 ruled out. - */ -export { recordNotFoundError }; - -/** - * The RECORD limb of `IDataEngine.update`'s declared result, for the four - * ingresses in this file whose call is BY-ID by construction (#16231). - * - * `update()` used to declare `Promise` and now declares the union its two - * dispatch paths actually answer: a record (or `null`) from the by-id exit - * (`driver.update`), or the affected-row COUNT from the predicate exit - * (`driver.updateMany`, #4639). Every call site below hands the engine a - * `where` naming exactly one primary key — or folds the row's id into the - * payload — with no `multi`, which `resolveEngineUpdateDispatch` resolves - * `by-id`. So the count limb is unreachable from here, and it is REFUSED - * loudly rather than cast away: a dispatch change that started routing these - * calls through `updateMany` would otherwise drop an affected count into a - * per-row receipt's `record` / `data` slot, where every consumer reads it as a - * row. - * - * The `null` limb is NOT refused and NOT narrowed away — it is passed through - * exactly as it was while the declaration said `any`. `update()`'s by-id exit - * answers `null` when the post-write readback leaves the caller's row scope - * (see `updateData`'s own note on that), while `DataProtocol`'s row receipts - * declare `record` / `data` non-null. That disagreement pre-dates this change, - * and this file preserves it rather than widening a shipped response shape as - * a rider; the assertion below is the one place it is written down. - */ -export function byIdUpdateRecord(result: Record | number | null): Record { - if (typeof result === 'number') { - throw new Error( - `A by-id update resolved an affected-row count (${result}) instead of a record. ` + - `This ingress addresses exactly one row, so the engine's predicate dispatch ` + - `(driver.updateMany) is not reachable from it — the dispatch ladder or this ` + - `call site changed.`, - ); - } - return result as Record; -} - -/** - * A 400 for a `$filter` ARRAY that looks like a filter AST but is not one. - * - * The message has to be *actionable from the request*, which is the whole point - * of rejecting here rather than letting a driver fail later: the caller sent a - * query parameter, so the error names the offending element and the vocabulary - * it was checked against — not a driver-internal builder state. - * - * Diagnoses the three shapes `isFilterAST` refuses, in the order they occur in - * practice. #4121. Sibling of {@link unusableFilterError}, which covers the - * non-array ways a filter fails to become one (#4181); both emit - * `INVALID_FILTER` so the condition has one wire code however it was reached. - */ -function malformedFilterArrayError(filter: unknown[]): Error { - const detail = describeMalformedFilter(filter); - const err: any = new Error( - `Malformed $filter: ${detail} A filter array is a comparison ` + - `[field, operator, value], a logical node ["and"|"or", ...conditions], or a ` + - `list of those. Recognised operators: ${[...VALID_AST_OPERATORS].sort().join(', ')}.`, - ); - err.status = 400; - err.code = 'INVALID_FILTER'; - return err; -} - -/** The specific reason a filter array failed `isFilterAST`, for the message above. */ -function describeMalformedFilter(filter: unknown[]): string { - const [first, second] = filter; - const isKeyword = typeof first === 'string' && ['and', 'or'].includes(first.toLowerCase()); - - // `["and"]` / `["or"]` with nothing to join. The one shape that still - // returned every row silently after #3948: the driver sets its join mode, - // matches no element, and emits no predicate. - if (isKeyword && filter.length < 2) { - return `logical node ["${String(first)}"] has no conditions to join.`; - } - // A bare triple whose operator is outside the AST vocabulary — the original - // `before` / `after` / `'not in'` case. - if (typeof first === 'string' && !isKeyword && typeof second === 'string' - && !VALID_AST_OPERATORS.has(second.toLowerCase())) { - return `unrecognised operator "${second}" in [${JSON.stringify(first)}, ...].`; - } - // An element that is neither a join keyword nor a nested condition. - const badIndex = filter.findIndex( - (item) => !Array.isArray(item) - && !(typeof item === 'string' && ['and', 'or'].includes(item.toLowerCase())), - ); - if (badIndex >= 0 && filter.some((item) => Array.isArray(item))) { - const bad = filter[badIndex]; - return `element ${badIndex} is ${bad === null ? 'null' : typeof bad}, ` + - `expected a condition array or a logical keyword.`; - } - return `${JSON.stringify(filter)} is not a recognised filter shape.`; -} - -/** - * The keys THIS file stamps onto every served document (`_diagnostics` via - * `decorateMetadataItem`, `_draft` via the draft-preview overlay) — and the - * strip that keeps them out of a persisted body (#4326) or a strict re-parse - * (cloud#971). - * - * The list itself lives in `@objectstack/spec` because this module PRODUCES the - * decoration while consumers in other layers (`service-automation`'s cold-boot - * flow bind, …) have to REMOVE it: one shared definition is what stops the two - * sides from drifting. See `spec/kernel/metadata-read-decorations.ts` for the - * full rationale and for why the ADR-0010 protection envelope (`_lock`, - * `_packageId`, …) is deliberately not stripped despite the shared spelling. - * - * Re-exported here so `@objectstack/metadata-protocol`'s public surface is - * unchanged. - */ -export { stripReadDecorations }; - -/** - * Guarantee a `view` body carries a top-level `name`. - * - * {@link ObjectStackProtocolImplementation.getMetaItems} only surfaces a - * sys_metadata overlay row when its parsed body has a top-level `name` (objects - * and dashboards include one; some view producers — notably loose `{ list }` - * fragments — do not, so the view is silently dropped from the object's view - * list and never appears as a tab). We stamp the save name here, at the single - * write chokepoint, without otherwise reshaping the document. - * - * Deliberately does NOT convert shape: both the `defineView` container form - * (`{ list, listViews, … }`) and the `{ name, object, viewKind, config }` - * record form are valid and the console consumes both — reshaping a container - * into a record risks producing an invalid record (e.g. a non-`.` - * name). Structural validity is enforced separately by the view metadata schema - * during the spec-validation step. No-op for non-view types and bodies that - * already carry a `name`. [#21412, #21470] A view's authored `name` reaches - * this function only when it equals `saveName`: `saveMetaItem` refuses a - * disagreeing one first, through `savedItemNameRefusal`, so keeping the - * authored `name` can no longer file a view under a second key. - * - * When `baseline` is provided (the registry entry this overlay will shadow), - * missing identity fields — `viewKind`, `object`, `label` — are inherited onto - * non-container bodies. A runtime personalization PUT (console column sort, - * inline edit, …) sends only the raw view config; persisting it verbatim makes - * the overlay replace the flattened package entry minus its identity, and the - * view silently drops out of every consumer that filters on - * `viewKind`/`object` (e.g. the switcher endpoint). See #2555. Container - * bodies are left untouched — `expandViewContainer` derives identity itself. - */ -export function normalizeViewMetadata(type: string, item: unknown, saveName: string, baseline?: unknown): unknown { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (singular !== 'view') return item; - if (!item || typeof item !== 'object' || Array.isArray(item)) return item; - const it = item as Record; - const patch = viewIdentityPatch(it, baseline); - if (it.name && !patch) return it; - return { ...it, ...(it.name ? undefined : { name: saveName }), ...patch }; -} - -/** - * Persist the operator spellings the spec's own schema normalized, and nothing - * else. objectui#2945. - * - * `ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`, - * so a stored `notEquals` / `gt` / `isNull` is folded to its canonical form - * during validation — and then the result was thrown away, because `saveMeta` - * persists the authored body verbatim (deliberately: `parsed.data` strips the - * Studio-only auxiliary fields that ride along with an overlay). The alias table - * `VIEW_FILTER_OPERATOR_ALIASES` therefore keeps acquiring *new* rows with every - * save, which is why it can never be retired: there is no point at which the - * last alias row is behind you. - * - * This grafts the normalization back on without giving up the verbatim body. - * It walks the authored value and the parsed value in lockstep **by structure** - * and copies across exactly one thing: an `operator` whose parsed value differs - * from the authored one. No key list to maintain — every filter site the schema - * knows about is covered, including ones added later — and nothing is added, - * removed, reordered or defaulted, so an auxiliary field cannot be lost the way - * a wholesale `parsed.data` swap would lose it. - * - * Returns the input itself when nothing changed, so the common case allocates - * nothing. - */ -export function graftNormalizedOperators(authored: unknown, parsed: unknown): unknown { - if (Array.isArray(authored)) { - if (!Array.isArray(parsed)) return authored; - let changed = false; - const out = authored.map((entry, i) => { - const next = graftNormalizedOperators(entry, parsed[i]); - if (next !== entry) changed = true; - return next; - }); - return changed ? out : authored; - } - - if (!authored || typeof authored !== 'object') return authored; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return authored; - - const a = authored as Record; - const p = parsed as Record; - let patch: Record | undefined; - - for (const [key, value] of Object.entries(a)) { - // The one value this function is allowed to rewrite. Guarded on both - // sides being strings so a `$`-token condition object — a different - // operator vocabulary entirely — cannot be reshaped by accident. - if (key === 'operator' && typeof value === 'string' && typeof p[key] === 'string') { - if (p[key] !== value) (patch ??= {})[key] = p[key]; - continue; - } - const next = graftNormalizedOperators(value, p[key]); - if (next !== value) (patch ??= {})[key] = next; - } - - return patch ? { ...a, ...patch } : authored; -} - -/** - * Persist the `groups` → `sections` fold the spec's own schema performed, and - * nothing else. #7134, the save-path half of #6926 / PR #7128. - * - * `FormViewSchema` carries `foldFormGroupsIntoSections` as a `.overwrite()` - * check, so a form authored with the legacy `groups` alias parses to one - * carrying canonical `sections` — and then the result was thrown away, because - * `saveMeta` persists the authored body verbatim (deliberately: `parsed.data` - * strips the Studio-only auxiliary fields that ride along with an overlay). A - * Studio-saved public form therefore reached every `sections`-reading consumer - * still spelled `groups`, and `packages/rest`'s `/forms/:slug` routes degraded - * on exactly that: an empty published field schema, an empty `allowedFields` - * whitelist on submit (#6920), and a 403 for every field on the anonymous - * lookup picker (a route since retired, #21180). Same shape of gap as - * {@link graftNormalizedOperators}, and the - * same consequence — while saves keep minting the authored spelling, the alias - * can never be retired and the objectui-side folds cannot be removed. - * - * ## Why this is a SIBLING of {@link graftNormalizedOperators}, not a parameter - * - * That function walks authored and parsed in lockstep by structure and copies - * across a changed SCALAR at a key both sides carry. `groups` → `sections` is a - * KEY MOVE — one key removed, another added — which its per-key loop cannot - * express: it iterates the AUTHORED keys and only ever patches a key already - * there, so it can neither drop `groups` nor introduce `sections`. Measured, not - * assumed (#7134). - * - * ## How the fold is DETECTED, rather than guessed - * - * At each structural position the fold is taken to have happened iff the author - * wrote `groups`, the parse did NOT keep it, and the parse produced `sections` - * in its place. That is the exact post-condition of - * `foldFormGroupsIntoSections`, so no list of "places a form can live" is - * maintained here: the top-level flattened overlay, `config` on a `ViewItem`, - * and `form` / `formViews.*` on a container are all covered by the same walk, - * and a form slot added later is covered without an edit. A `groups` key the - * schema keeps (a different vocabulary — `app.zod`'s nav groups, a passthrough - * record) fails the middle test and is left alone. - * - * ## What is moved is the AUTHORED array, not the parsed one - * - * `parsed.data`'s sections carry schema defaults (`collapsible`, `collapsed`, - * `columns`); persisting those would be the wholesale swap this whole design - * avoids. The authored array moves verbatim — the producer's fold is - * `FormSectionSchema` → `FormSectionSchema` with no sub-key rewriting, so the - * moved value is already the canonical one. When the author wrote BOTH keys the - * producer keeps `sections` and drops `groups`, empty array included; so does - * this. - * - * Returns the input itself when nothing changed, so the common case allocates - * nothing. - */ -export function graftFoldedFormSections(authored: unknown, parsed: unknown): unknown { - if (Array.isArray(authored)) { - if (!Array.isArray(parsed)) return authored; - let changed = false; - const out = authored.map((entry, i) => { - const next = graftFoldedFormSections(entry, parsed[i]); - if (next !== entry) changed = true; - return next; - }); - return changed ? out : authored; - } - - if (!authored || typeof authored !== 'object') return authored; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return authored; - - const a = authored as Record; - const p = parsed as Record; - - // The post-condition of `foldFormGroupsIntoSections`, read off this position. - const folded = a.groups !== undefined && p.groups === undefined && p.sections !== undefined; - - let patch: Record | undefined; - for (const [key, value] of Object.entries(a)) { - // When the fold fired, the parsed node structurally corresponding to the - // authored `groups` is `sections` — descending against `p.groups` there - // would silently stop walking at the one key this function is about. - const counterpart = folded && key === 'groups' ? p.sections : p[key]; - const next = graftFoldedFormSections(value, counterpart); - if (next !== value) (patch ??= {})[key] = next; - } - - if (!folded) return patch ? { ...a, ...patch } : authored; - - const { groups, ...rest } = patch ? { ...a, ...patch } : a; - // `sections` wins when the author wrote it — including as an empty array, - // which is what the producer's fold does and therefore what already renders. - return rest.sections !== undefined ? rest : { ...rest, sections: groups }; -} - -/** - * How many verify-and-restore rounds {@link projectStorableViewBody} may take. - * One round per level of key-moved nesting is what convergence costs: a - * `groups` → `sections` fold whose sections carried a moved key of their own - * (the `visibleOn` → `visibleWhen` fold, until that alias retired at 18) is - * two levels. A `view` body is a bounded document, so a - * body still diverging after this many rounds is not converging at all. - */ -const STORABLE_VIEW_MAX_ROUNDS = 8; - -/** A JSON object — not an array, not `null`. */ -export function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Equality as the store sees it: `JSON.stringify` semantics, so a key whose - * value is `undefined` is the same as an absent key, and key ORDER does not - * count (the list overlay's `type` default is re-applied in declaration - * position, so a re-parse can reorder keys it did not change). - */ -function storableEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - return a.every((entry, i) => storableEqual(entry, b[i])); - } - if (isPlainRecord(a) && isPlainRecord(b)) { - const keysA = Object.keys(a).filter((k) => a[k] !== undefined); - const keysB = Object.keys(b).filter((k) => b[k] !== undefined); - if (keysA.length !== keysB.length) return false; - return keysA.every((k) => storableEqual(a[k], b[k])); - } - return false; -} - -/** - * The one authored key, among those the parse did not keep at this position, - * whose value has the shape of `value` — the key a moved key was moved FROM. - * `undefined` when there is none, or more than one. - */ -function soleMovedFrom(dropped: readonly string[], authored: Record, value: unknown): string | undefined { - const sameShape = (candidate: unknown): boolean => { - if (Array.isArray(value)) return Array.isArray(candidate) && candidate.length === value.length; - if (isPlainRecord(value)) return isPlainRecord(candidate); - return typeof candidate === typeof value && !isPlainRecord(candidate) && !Array.isArray(candidate); - }; - const matches = dropped.filter((key) => sameShape(authored[key])); - return matches.length === 1 ? matches[0] : undefined; -} - -/** - * One round of {@link projectStorableViewBody}: the storable node at one - * position, built from the authored node, the parsed node, and — from the - * second round on — the re-parse of the previous round's body (`reparsed`) and - * that body itself (`previous`). - * - * - A key the author wrote and the parse kept is stored with its PARSED value, - * recursively. That is what normalises (`notEquals` → `not_equals`, - * `exportOptions: ['csv']` → `{ formats: ['csv'] }`) without storing a - * default a nested parse added. - * - A key the author wrote and the parse did not keep is not stored: an - * undeclared key, a console decoration, or the old spelling of a moved key. - * - A key the parse added is stored only on evidence. The previous round's body - * did not carry it, so if the re-parse of that body produced the same value, - * the parse applies it by itself — a schema DEFAULT, left to the parse - * (ADR-0087's `storable` rule). If the re-parse did NOT reproduce it, the value - * came from something the author wrote under another key — a MOVED key - * (`groups` → `sections`) — and it is stored, - * projected against the authored key it moved from when that key is the only - * one of its shape, and stored whole otherwise. Once stored it stays stored - * in later rounds, so the rounds only ever add. - * - Anything else — a value whose shape the parse changed (`['csv']` → - * `{ formats }`), an array whose length it changed, a leaf — is stored as - * parsed. - */ -function buildStorableViewNode(authored: unknown, parsed: unknown, reparsed: unknown, previous: unknown): unknown { - if (isPlainRecord(parsed) && isPlainRecord(authored)) { - const a = authored; - const r = isPlainRecord(reparsed) ? reparsed : undefined; - const prev = isPlainRecord(previous) ? previous : undefined; - const out: Record = {}; - const dropped: string[] = []; - for (const [key, value] of Object.entries(a)) { - if (value === undefined) continue; - if (parsed[key] === undefined) { - dropped.push(key); - continue; - } - out[key] = buildStorableViewNode(value, parsed[key], r?.[key], prev?.[key]); - } - for (const [key, value] of Object.entries(parsed)) { - if (value === undefined || key in out || a[key] !== undefined) continue; - const storedBefore = prev !== undefined && prev[key] !== undefined; - if (!storedBefore && (r === undefined || storableEqual(r[key], value))) continue; - // Projected against the key it moved from when that key is the only - // one of its shape; otherwise (a string folded into an expression - // object, as the retired `visibleOn` → `visibleWhen` fold did) the - // parsed value is the moved key's value, and it is stored whole. - const from = soleMovedFrom(dropped, a, value); - out[key] = from === undefined ? value : buildStorableViewNode(a[from], value, r?.[key], prev?.[key]); - } - return out; - } - if (Array.isArray(parsed) && Array.isArray(authored) && authored.length === parsed.length) { - const r = Array.isArray(reparsed) && reparsed.length === parsed.length ? reparsed : undefined; - const prev = Array.isArray(previous) && previous.length === parsed.length ? previous : undefined; - return parsed.map((entry, i) => buildStorableViewNode(authored[i], entry, r?.[i], prev?.[i])); - } - return parsed; -} - -/** - * [#20051] What the `view` write door STORES: the parsed value of every key the - * request body carried — undeclared keys dropped, schema defaults NOT - * materialised. Ruling 甲's end state ("a saved view is the parsed body") under - * letter B of its follow-up ruling, which puts views under the rule ADR-0087 - * (addendum 2026-08-01b) records for flows: `storable` excludes schema - * defaults, so a stored row never pins the day's default. - * - * It replaces, for `view`, the two grafts that ran over the verbatim body — - * {@link graftNormalizedOperators} (a value normalised in place) and - * {@link graftFoldedFormSections} (a key moved). Both are special cases of it: - * a parsed value is stored for every authored key, and a moved key is found by - * evidence rather than by name, so a move no graft names (the - * `visibleOn` → `visibleWhen` fold did, until that alias retired at 18) is - * covered too. Every other type keeps the two grafts over its verbatim body. - * - * ## Why a re-parse, and what it proves - * - * The parse output alone cannot say which of the keys it added are defaults and - * which are moved keys: `sections` folded from `groups` and `collapsible` - * defaulted to `false` look the same. So the body built from the authored keys - * is parsed again, and a key the parse added is kept exactly when that - * re-parse does NOT reproduce it (see {@link buildStorableViewNode}). The loop - * ends when the re-parse of the stored body EQUALS the parse of the request - * body — the invariant the stored row is held to: a GET → PUT of it is judged - * the same, and every reader that parses it sees what the save accepted. - * - * `converged: false` — the loop found no body meeting that invariant (a - * re-parse refused an intermediate body, or a round added nothing). The caller - * then stores `parsed` itself: no key is lost, only that row's defaults are - * pinned. Nothing in the `view` schema reaches that arm today; it is the - * fail-safe for a schema change that makes one of the rounds' bodies invalid. - */ -export function projectStorableViewBody( - authored: unknown, - parsed: unknown, - reparse: (body: unknown) => { ok: true; data: unknown } | { ok: false }, -): { body: unknown; converged: boolean } { - let candidate = buildStorableViewNode(authored, parsed, undefined, undefined); - for (let round = 0; round < STORABLE_VIEW_MAX_ROUNDS; round++) { - const again = reparse(candidate); - if (!again.ok) break; - if (storableEqual(again.data, parsed)) return { body: candidate, converged: true }; - const next = buildStorableViewNode(authored, parsed, again.data, candidate); - if (storableEqual(next, candidate)) break; - candidate = next; - } - return { body: parsed, converged: false }; -} - -/** - * [#20101] Give a page body that omits `type` the default `PageSchema` - * declares for it, and change nothing else. - * - * `PageSchema` declares `type: PageTypeSchema.default('record')`, so a page - * authored without `type` IS a record page. The save gate parses with that - * default and then persists the authored body verbatim (ADR-0005 appendix - * (c), {@link resolveOverlaySchema}), and every read serves a stored - * row without parsing it. A record page written the natural way, with the - * defaulted key left out, therefore reached every reader of the served body - * with no `type` at all. A consumer that picks an object's record page by - * `type === 'record'` never picked it. The parse applied the declared default - * and nothing the server sends carried it. - * - * One function, applied at both seams: - * - the WRITE (`saveMetaItem`, once the schema gate has accepted the body), - * so a new row stores the key and a GET → PUT round-trip of the served - * document stays byte-identical (#4326's invariant). A fix on the read - * side alone breaks that round-trip: the first re-save of every such page - * would write a one-key change and a history row that nobody authored; - * - the READ ({@link ObjectStackProtocolImplementation.convertStoredItem}, - * the rehydration seam every stored row passes through), so a row stored - * before this fix is served with the default and never rewritten. The row - * at rest keeps its bytes and there is no migration. - * - * The value is READ from the registered `page` schema, the one the save gate - * validates with, and is never spelled here. A literal would be a second, - * silent spelling of the default: the consumer-side `?? 'record'` this - * replaces, moved up one layer. - * - * ⛔ The scope is `type` on `page` and nothing wider. It is not a general - * "fill every declared default" pass. Other stored types declare top-level - * defaults in the same position, and each of those is its own decision. - * - * Only an ABSENT key is filled (zod applies a default to `undefined`, not to - * `null`), and an explicit value is left as it is. Returns the input itself - * when nothing is filled. - */ -export function withDeclaredPageTypeDefault(type: string, item: unknown): unknown { - if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'page') return item; - if (!item || typeof item !== 'object' || Array.isArray(item)) return item; - if ((item as Record).type !== undefined) return item; - const pageSchema = getMetadataTypeSchema('page') as { shape?: Record } | undefined; - const declared = pageSchema?.shape?.type?.safeParse(undefined); - if (!declared?.success || declared.data === undefined) return item; - return { ...(item as Record), type: declared.data }; -} - -/** - * #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view - * overlay is missing but the registry entry it shadows carries. The overlay's - * own fields always win. Returns `null` (nothing to inherit) for `defineView` - * container bodies — their identity is derived at expansion — and for - * absent/invalid baselines. - */ -function viewIdentityPatch(overlay: Record, baseline: unknown): Record | null { - if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) return null; - if ('list' in overlay || 'listViews' in overlay || 'formViews' in overlay) return null; - const b = baseline as Record; - const patch: Record = {}; - for (const key of ['viewKind', 'object', 'label'] as const) { - if (overlay[key] === undefined && b[key] !== undefined) patch[key] = b[key]; - } - return Object.keys(patch).length > 0 ? patch : null; -} - -/** - * [#21803] The packages that can ship an item of one type, read once from the - * registry's listings: per name, and the ones probed for every name. See - * `ObjectStackProtocolImplementation.shippingPackagesOf`. - */ -export type ShippingPackages = { - readonly byName: ReadonlyMap>; - readonly anyName: ReadonlySet; -}; - -/** - * [#23198] What a write did to a view container's stored rows, as - * `ObjectStackProtocolImplementation.reconcileViewExpansions` takes it. - * - * - `binding`: one row was hydrated (a publish, a republish, a rollback, a - * revert, a replica's convergence, the boot, the list read). `binding` is - * the row's package binding, `undefined` for the package-less row, and - * `expansion` is everything that row's body expands to now. - * `reboundFrom` is set when the write moved the row from another binding - * (an orphan adoption): `reboundFrom.binding` is the binding it left, - * `undefined` for the package-less row. That binding's record moves to - * `binding`, so the row reconciles against what it registered under either. - * - `removed: 'every-row'`: a row of the name was removed (a reset, a delete, - * a revert's removal, a replica's removal). The heal is handed no binding, - * so it addresses every row of the name, and the set they imply is empty. - */ -type ViewContainerWrite = - | { - readonly container: string; - readonly binding: string | undefined; - readonly expansion: ReadonlyArray>; - readonly reboundFrom?: { readonly binding: string | undefined }; - } - | { readonly container: string; readonly removed: 'every-row' }; - -/** - * [#23514, ADR-0029 D9.9] The state a refusal of a package's object row beside - * a package-less owner (the `'sys_metadata'` sentinel) answers, which decides - * the way out its prescription names - * (`ObjectStackProtocolImplementation.unboundOwnerPackageRefusal`). - */ -interface UnboundOwnerRefusalState { - /** The save's mode; a draft save registers nothing. */ - mode?: 'draft' | 'publish'; - /** - * The packages whose ACTIVE rows of the object are stored beside the - * package-less one (none, the package the request names, or others): - * each must be deleted by naming its package, because a delete naming no - * package resolves to the one binding present, or is refused 409 over two - * ({@link ObjectStackProtocolImplementation.packagelessAddress}). - */ - storedPackages?: readonly string[]; - /** The caller named no package, and the save's address resolved to the package's row. */ - namedNoPackage?: boolean; - /** - * A publish naming no package of a draft bound to NO package, which the - * draft doors' address adopts into the one package binding stored - * (`packagelessDraftAddress`: `key: null`, `into: P`). No publish moves that - * draft into `P`: with `P`'s row gone it promotes into the package-less - * definition, and one naming `P` finds no `P` draft (`NO_DRAFT`). - */ - adoptedDraft?: boolean; - /** - * [#23712] A DRAFT save naming no package (with `namedNoPackage`): the - * packages whose drafts of the object are stored with no package-unbound - * draft beside them. Once the rows are deleted, the draft address is the - * one package's draft (`packagelessDraftAddress`), so each is discarded - * by naming its package before the save writes the package-less draft. - */ - strandedDrafts?: readonly string[]; - /** - * Where the refusal is raised: the save door (default), a draft promotion - * (the per-item publish and `publish-drafts`), or a registration of a - * stored row (boot, write-through). - */ - at?: 'save' | 'promote' | 'registry'; -} - -/** - * What a hydration of one stored row is told about it: - * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the - * expansion hydration it hands the row to. - * - * - `packageId`: the row's package binding. - * - `organizationId`: the row's scope, `null` for an environment-wide row - * (#6602). - * - `reboundFrom`: [#23198] set only when the write that is being hydrated - * moved the row from another binding (an orphan adoption). `packageId` is - * the binding it left, `null` for the package-less row. See - * {@link ViewContainerWrite}. - */ -type HydrationOptions = { - packageId?: string | null; - organizationId: string | null; - reboundFrom?: { readonly packageId: string | null }; -}; - -/** - * ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope - * onto a returned item so artifact-level lock/packageId/provenance - * always wins over whatever was persisted in the `sys_metadata` overlay - * row. Returns `item` unchanged when no artifact baseline is available. - * - * The artifact's `_packageId`, `_packageVersion` and `_provenance` are the - * source of truth — an overlay copy may pre-date the artifact's protection - * declaration and would otherwise mask it. - * - * [#21738] The lock family (`_lock`, `_lockReason`, `_lockDocsUrl`, - * `_lockSource`) is NOT decided here: it follows the one item-lock resolution - * ({@link resolveItemLock}). The artifact's family is copied over the item's - * only when the artifact's lock is the one that binds, which is exactly the - * case the sentence above exists for (an overlay never masks a packaged lock). - * An artifact that declares no lock, or an explicit `'none'`, leaves the - * item's own family in place. Before this, any declared artifact `_lock` was - * copied, `'none'` included, so an artifact's explicit `'none'` erased a - * stored row's `'full'` from the served body and from the read envelope while - * the write door, which skips `'none'`, still refused the save. - * - * [#21761, #21803] A read that resolved the item's lock from its address - * ({@link resolveArtifactLockLayer} and {@link resolveOverlayLockLayer} into - * {@link resolveItemLock}) passes that answer as `itemLock`, and the body - * carries ITS lock family ({@link withItemLockFamily}): the binding row's or - * the binding package's, which need not be the document the body was served - * from, and no `_lock*` key at all when nothing binds. `artifactItem` then - * contributes the provenance fields only: content, and with it provenance, - * stays prefer-local (ADR-0048). Without `itemLock` the item's own body - * stands in for the overlay layer over `artifactItem` alone, as before: the - * registry's hydration and a previewed draft, neither of which is a read's - * lock answer. - * - * [#23074] An artifact lends its envelope only to a body of its OWN identity. - * For a type whose identity is a pair ({@link ITEM_KEY_DISCRIMINATORS}: - * `email_template`'s `(name, locale)`), that is the same bundle member, read - * by {@link bundleMemberOf}, the identity's one declaration; an artifact of - * another member is treated as no artifact at all. So an environment overlay - * of a member the package does not ship (a `zh-CN` template beside a shipped - * `en-US`) keeps the tenant authorship it was registered with, and is never - * read as packaged code: `isCodeArtifactBody` false, no `_packageId` / - * `_packageVersion` / `_provenance` stamp, no artifact `_lock*`. Grafted, it - * read as the bundle's own artifact of that member to the registry's artifact - * lookup and its overlay-entry retirement, and the read doors served it under - * the package's provenance. The shipped member's overlay is unchanged: each - * caller looks the artifact up at the body's own member - * (`lookupArtifactItem`'s `member`), so it still wears that member's - * artifact's envelope. A registry that ignores `member` can answer another - * member's artifact, and this check is what keeps that answer off the body. - * Every other type has no members, so `bundleMemberOf` is `undefined` on both - * sides and the merge is exactly what it was. ⛔ The lock family a read - * resolved (`itemLock`) is applied before this check and is not moved by it: - * a lock binds the NAME, at the read and at the `_lock` gate alike - * ({@link resolveItemLock} over every package that ships the name). - */ -function mergeArtifactProtection( - type: string, - item: unknown, - artifactItem: unknown, - itemLock?: ItemLock, -): unknown { - if (item === undefined || item === null) return item; - if (itemLock !== undefined) item = withItemLockFamily(item, itemLock); - if (artifactItem === undefined || artifactItem === null) return item; - const a = artifactItem as Record; - if (typeof a !== 'object') return item; - if (bundleMemberOf(type, a) !== bundleMemberOf(type, item)) return item; - const out: Record = { ...(item as Record) }; - if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') { - if (a._lock !== undefined) out._lock = a._lock; - if (a._lockReason !== undefined) out._lockReason = a._lockReason; - if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl; - if (a._lockSource !== undefined) out._lockSource = a._lockSource; - } - if (a._packageId !== undefined) out._packageId = a._packageId; - if (a._packageVersion !== undefined) out._packageVersion = a._packageVersion; - if (a._provenance !== undefined) out._provenance = a._provenance; - return out; -} - -/** - * [#23074] The bundle member a body IS, for a type whose identity is a pair - * ({@link ITEM_KEY_DISCRIMINATORS}): `itemDiscriminator` under the type's - * canonical spelling, so the canonical member for a body that declares none. - * `undefined` for every other type, which has no members. The member - * {@link mergeArtifactProtection} matches an artifact against, and the one - * each of its callers looks the artifact up at. - */ -function bundleMemberOf(type: string, body: unknown): string | undefined { - return itemDiscriminator(canonicalMetaType(type), body); -} - -/** - * [#22024, ADR-0048, ADR-0010 §3.3] The package whose artifact envelope - * ({@link mergeArtifactProtection}: `_packageId`, `_packageVersion`, - * `_provenance`) a served item wears: the package the read names, else the - * package the served item itself is bound to, as the step that served it - * stamped it (a stored row's `package_id`, a container expansion's own - * package, the MetadataService's or the registry's item's `_packageId`). - * - * The one rule both read doors take: the list - * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each - * item it serves, and the by-name read - * ({@link ObjectStackProtocolImplementation.getMetaItem}) for the item it - * serves. So one served body wears one envelope on both doors. - * - * A read naming no package used to take the envelope of the first composite - * the registry holds under the name, which is the first-registered package's. - * Where two packages ship the name and the body served is another package's - * (its stored row, or its stored copy of a container they both ship), the - * answer served that package's body under the first-registered package's - * `_packageId`. A served item bound to no package (a package-less stored row, - * a registry entry with no package) names none, so its lookup is the - * package-less one it always was. ⛔ The lock is not chosen here: it is the - * one item-lock resolution's ({@link resolveItemLock}), from the read's own - * address. - */ -function envelopePackageId(requestedPackageId: string | undefined, served: unknown): string | undefined { - return requestedPackageId ?? (served as { _packageId?: string } | null | undefined)?._packageId; -} - -/** - * [#28831, #22024, ADR-0146 D8] The artifact envelope an item of the - * per-package layer ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) - * wears: the artifact the read looked up at the item's package - * ({@link envelopePackageId}) when it is that package's OWN, else none. - * - * The artifact lookup falls back to the first code package that ships the - * name when the item's package ships none (`registry.getArtifactItem`'s - * any-composite tier). On the effective read that is how a package-less row - * standing in for a base wears the base's envelope. On the per-package layer - * every row is seated under its own package, so the fallback stamped a - * dependent package's row, or a `.custom` pair's undeclared row, under a base - * item's name with the BASE's `_packageId`, `_packageVersion` and - * `_provenance`: the export and the anonymous form doors' withdrawal layer - * read it as the base's. - * - * One exception keeps today's stamp: a stored row of the item's package that - * is the served override of the artifact's package, judged by - * {@link isServedOverrideRowOf} on the row's own `package_id` (never a body - * `_packageId`) at the item's own bundle member. It is served as its base's - * item, under its base's envelope. - * - * An item bound to no package (`boundPackageOf` answers `undefined`) keeps - * the lookup's answer, as before. ⛔ The lookup itself is not narrowed: its - * other callers rely on the fallback. - */ -function perPackageLayerEnvelope( - type: string, - item: unknown, - itemPackageId: string | undefined, - artifact: unknown, - rowsOfName: readonly any[], -): unknown { - if (artifact === undefined || artifact === null || typeof artifact !== 'object') return artifact; - const own = boundPackageOf(itemPackageId); - if (own === undefined) return artifact; - const lender = (artifact as { _packageId?: unknown })._packageId; - 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; - try { - return bundleMemberOf(type, storedRowDocument(row)) === member; - } catch { - return false; - } - }); - return declared ? artifact : undefined; -} - -/** - * [#22057] The package a body is bound to, when it is bound to one: a - * non-empty id that is not the `'sys_metadata'` sentinel a package-less row - * carries. `undefined` for a package-less body. - */ -function boundPackageOf(packageId: unknown): string | undefined { - return typeof packageId === 'string' && packageId !== '' && packageId !== 'sys_metadata' ? packageId : undefined; -} - -/** - * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE - * DERIVED, and therefore must never be persisted from a caller's body. - * - * {@link mergeArtifactProtection} recomputes all three from the artifact on - * every read, so a copy stored inside a `sys_metadata` body is never - * load-bearing: removing it is observable ONLY where that copy was a lie. - * - * ⛔ The `_lock*` family is deliberately NOT here, though it shares the - * underscore spelling and the same ADR-0010 envelope. A lock is - * AUTHOR-DECLARABLE (`protection.lock`, translated into `_lock*` by - * `applyProtection`), so dropping one is the FAIL-OPEN direction — cloud PR - * #2065 drew that line at its own producer and this door follows it. - * - * ⛔ Nor is this a second `METADATA_READ_DECORATIONS`. That list is shared - * with every consumer that re-parses a SERVED document (`spec`'s - * `metadata-read-decorations.ts`), and its header states on purpose that the - * protection envelope stays on a served body so provenance survives a - * re-parse. This strip is scoped to the WRITE door alone. - */ -const DERIVED_PROVENANCE_KEYS = ['_packageId', '_packageVersion', '_provenance'] as const; - -/** - * [#16702] Remove {@link DERIVED_PROVENANCE_KEYS} from a body about to be - * PERSISTED into `sys_metadata`. - * - * A **silent** strip, for the same reason {@link stripReadDecorations} is - * silent: the standard Studio `GET` → edit → `PUT` round-trip echoes whatever - * the served document carried, and refusing that round-trip would be hostile - * for keys the server stamped itself. What it restores is the invariant the - * `_provenance: 'org'` docblock in {@link - * ObjectStackProtocolImplementation.applyObjectRegistryMutation} already - * states: every row this door writes is tenant-authored by definition, so the - * server states that fact rather than reading it back from the caller. - * - * Returns the SAME reference when there is nothing to strip, so the common - * path allocates nothing. Non-object inputs pass through — the caller's own - * validation owns those. - */ -function stripDerivedProvenance(item: unknown): unknown { - if (!item || typeof item !== 'object' || Array.isArray(item)) return item; - const dict = item as Record; - if (!DERIVED_PROVENANCE_KEYS.some((k) => k in dict)) return item; - const next = { ...dict }; - for (const k of DERIVED_PROVENANCE_KEYS) delete next[k]; - return next; -} - -/** - * [#16702] State what every `sys_metadata` row IS — tenant-authored, ADR-0010 - * `_provenance: 'org'` — on a COPY of a body about to be REGISTERED. - * - * The same sentence {@link - * ObjectStackProtocolImplementation.applyObjectRegistryMutation} and the boot - * `object` limb already write, said once for every OTHER type at the one - * hydration choke point they do not share. Without it the row's own bytes - * decide: `isCodeArtifactBody` accepts a truthy non-sentinel `_packageId` with - * non-`org` provenance, `SchemaRegistry.getArtifactItem`'s bare-key fallback - * returns the overlay AS an artifact, `isArtifactBacked` turns true, and - * `saveMetaItem`'s overlay gate refuses the tenant's next write to their own - * item with `NOT_OVERRIDABLE` — permanently, because the next boot re-derives - * the same verdict from the same row (cloud#970's shape, for non-`object` - * types). - * - * ⚠️ Both callers — the container in `hydrateOverlayIntoRegistry` and, since - * #21511, each view expansion it registers (`expandRuntimeViewContainer` - * under `tenantAuthored`) — apply it BEFORE {@link mergeArtifactProtection}, - * and the order is the whole contract: where a real artifact exists the artifact's - * envelope still overwrites `_provenance` (and `_packageId` / - * `_packageVersion` / `_lock*`) on the way out, so package protection is - * untouched — ADR-0010 §3.3 precedence is unchanged in both directions. - * - * On a COPY, always: `registerItem` hands the body to `applyProtection`, which - * mutates in place, and the callers own their `data`. - */ -function stateTenantAuthorship(data: unknown): unknown { - if (!data || typeof data !== 'object' || Array.isArray(data)) return data; - return { ...(data as Record), _provenance: 'org' }; -} - -/** - * [#23233, #28594, #28673] A stored row's body with its package binding stated - * as `_packageId` from the row's `package_id` COLUMN, and only from it: the one - * rule every read of a stored row states its package by. A `_packageId` the - * body carries is not served: replaced by the column's package, or dropped when - * the row is bound to no package ({@link boundPackageOf}). The save door - * already strips a body's stamp ({@link stripDerivedProvenance}: the server - * states provenance, a caller never does), but a row at rest can still carry - * one, and a read that stamped the column only while the body named none - * echoed it back. The binding is read, never derived. A body that already - * states the column's binding is returned as it is; any other is a copy, so - * the caller's body (a cached row's among them) is never written. - * - * The readers: the list read ({@link storedOverlayEntries}), the list merge - * ({@link mergePackageAwareOverlay}), the draft list, the by-name active and - * draft reads, the by-name draft preview, and the registry hydration - * ({@link ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry}) with - * the items a view container row expands to. - */ -export function withRowBinding(data: unknown, packageId: unknown): unknown { - if (!data || typeof data !== 'object' || Array.isArray(data)) return data; - const binding = boundPackageOf(packageId); - if ((data as { _packageId?: unknown })._packageId === binding) return data; - const bound: Record = { ...(data as Record) }; - if (binding === undefined) delete bound._packageId; - else bound._packageId = binding; - return bound; -} - -/** - * [#21639, #21638] Is this artifact a view ITEM — a view a package ships under - * its own name, `viewKind` set (the spec's ViewItem) — rather than a container - * or anything else? The save door's "a view item a package ships" and the - * package attribution of a container row both ask exactly this, positively: - * a body that is neither a container nor a view item is not a view item. - */ -function isShippedViewItem(artifact: unknown): boolean { - if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return false; - const viewKind = (artifact as { viewKind?: unknown }).viewKind; - return typeof viewKind === 'string' && viewKind !== ''; -} - -/** - * [#22877] The by-name doors' bundle member selector: the request member, and - * the `?member=` query parameter in each REST route's closed set (ruling - * 6116165299, Q2 i). Named after what the stored column holds - * (`sys_metadata.bundle_member`) and type-neutral, never `locale`: on `/meta`, - * `?locale=` is the response translation locale. - */ -export const BUNDLE_MEMBER_SELECTOR = 'member'; - -/** - * [#22877] The longest member a stored row can carry — the declared - * `maxLength` of `sys_metadata.bundle_member` (`@objectstack/metadata-core` - * `SysMetadataObject`, pinned equal in `protocol.bundle-member-selector.test.ts`). - * A longer selector names no member that could be stored, so it is refused as - * malformed rather than answered as an absent member. - */ -export const BUNDLE_MEMBER_MAX_LENGTH = 100; - -/** - * [#22877] THE one reading of the bundle member selector, shared by every - * by-name door (`getMetaItem`, `deleteMetaItem`, `publishMetaItem`, - * `historyMetaItem`, `rollbackMetaItem`). - * - * Returns the member as named, trimmed, or `undefined` when none was named, - * which every door reads as the CANONICAL member, as the registry's bare-name - * read does. Naming the canonical value (`en-US`) is the same request. A - * well-formed member with nothing stored or shipped is not refused here: each - * door answers it as it answers an absent item. - * - * Refuses, with a located 400 `VALIDATION_ERROR` — the code the REST door's - * recognition refusal answers for a misspelled selector on the same routes — - * a selector on a type whose identity is its name alone, and a value that - * cannot name a member: not a string, blank, or longer than - * {@link BUNDLE_MEMBER_MAX_LENGTH}. The message names the parameter. The REST - * door renders it through its one error door (`handleRouteError`), as it - * renders every refusal this protocol raises. ⛔ A blank selector is never read as - * absent: `?member=` asked for something, and answering the canonical member - * for it would be a guess. - */ -export function resolveBundleMemberSelector(type: string, member: unknown): string | undefined { - if (member === undefined) return undefined; - // Either spelling of the type: the doors ask after their boundary fold, - // and a direct caller may not have folded. - const singular = canonicalMetaType(type); - const disc = ITEM_KEY_DISCRIMINATORS[singular]; - const refuse = (sentence: string): never => { - const err = new Error(`The "${BUNDLE_MEMBER_SELECTOR}" selector ${sentence}`) as Error & { - code: 'VALIDATION_ERROR'; status: 400; param: string; - }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.param = BUNDLE_MEMBER_SELECTOR; - throw err; - }; - if (!disc) { - return refuse( - `names a bundle member, and a '${singular}' item has none: its identity is its name alone. ` - + `Remove the selector.`, - ); - } - const example = `'${disc.canonical}'`; - if (typeof member !== 'string') { - return refuse(`must be one string: the ${disc.field} of one '${singular}' member, e.g. ${example}.`); - } - const value = member.trim(); - if (value === '') { - return refuse( - `is blank. Name one '${singular}' member by its ${disc.field} (e.g. ${example}), or omit the ` - + `selector to address the canonical member (${example}).`, - ); - } - if (value.length > BUNDLE_MEMBER_MAX_LENGTH) { - return refuse( - `is ${value.length} characters long; a stored '${singular}' member holds at most ` - + `${BUNDLE_MEMBER_MAX_LENGTH}, so it names no member. Name one by its ${disc.field} (e.g. ${example}).`, - ); - } - return value; -} - -/** - * [#22877] The discriminator value of the member a selector addresses, for a - * registry call or a body comparison: the selector itself, or the canonical - * member when none was named. `undefined` for a type with no members. - */ -export function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { - const disc = ITEM_KEY_DISCRIMINATORS[canonicalMetaType(type)]; - if (!disc) return undefined; - return member?.trim() || disc.canonical; -} - -/** - * [#22877] `MetadataMutationEvent.member` for the member a write addressed, - * as a spread: empty for the canonical member and for a type with no members, - * so those events keep the shape they always had. - */ -function mutationEventMember(type: string, member: string | null | undefined): { member?: string } { - const value = overlayMemberValue(type, member); - return typeof value === 'string' ? { member: value } : {}; -} - -/** - * [#22877] One item of a package commit's revert plan (ADR-0067), as - * `sys_metadata_commit.items` stores it: `member` is the bundle member the - * item is, for a type whose identity is a pair, and is absent for the - * canonical member and every type with no members — so the plan of every - * other item is stored byte-identical to what it was. [#23073] The commit - * timeline serves it on every item of a bundled type, the canonical member - * spelled out ({@link ObjectStackProtocolImplementation.listCommits}). - */ -export type CommitPlanItem = { type: string; name: string; existedBefore: boolean; prevVersion: number | null; member?: string }; - -/** [#22877] The identity of a plan item or a draft within one batch: two members of one name are two. */ -function commitPlanKey(item: { type: string; name: string; member?: string }): string { - return `${item.type}/${item.name}\u0000${item.member ?? ''}`; -} - -/** - * [#22877] A repository call's `member` option as a spread: present only when a - * member was named, so a call addressing the canonical member — and every call - * for a type with no members — passes exactly the options it always did. - */ -export function memberOption(member: string | undefined): { member?: string } { - return member !== undefined ? { member } : {}; -} - -/** [#22877] {@link mutationEventMember} for the member a written BODY belongs to. */ -function mutationEventMemberOfBody(type: string, body: unknown): { member?: string } { - const value = overlayMemberOfBody(type, body); - return typeof value === 'string' ? { member: value } : {}; -} - -/** - * One place a stored `sys_metadata` row of an item can sit, as - * {@link servedOverlayRowCandidates} orders them: a spelling of the type - * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 - * residue), and the package the row is bound to: the address's package, `null` - * for the package-less row, or `undefined` for any row (an address that names - * no package). Every row in a candidate's place is the environment's - * (ADR-0131 D6/D7: the reads leave a legacy organization's rows out). - */ -interface ServedOverlayRowCandidate { - readonly spelling: 'canonical' | 'other'; - readonly packageId: string | null | undefined; - /** - * [#28409, ADR-0146 D8 item 2] Set on the one candidate that is a base's - * declared override: the base package whose name the row must override. - * `packageId` is then the base's pair (`.custom`), and a row there - * holds the candidate only when it declares the override - * ({@link isServedOverrideRowOf}); an undeclared row of the pair is that - * package's own item, never the base's. - */ - readonly overrideOf?: string; -} - -/** - * [#21804] Where a stored row the list read sits, beside the package it is - * bound to: the type spelling it is stored under (the row's own `type` - * column). - */ -interface StoredRowPlace { - readonly type: string; -} - -/** - * [#21804, ADR-0131 D6, ADR-0048] THE served-row resolution: the order in - * which the stored rows of one item are tried for the CONTENT a read serves. - * The first candidate that holds a row is the served row. - * - * - Scope (ADR-0131 D6): the environment's rows only. A legacy - * organization-scoped row is refused at boot - * (the v18 boot refusal, ADR-0131 D10 item 5) - * and never served, by any read: the anonymous form doors' read of the - * Default Organization's legacy layer was deleted when the v18 migration - * ceremony began carrying those rows' withdrawals into the environment - * (ADR-0131 C7, rulings A on #22008 and #22011). - * - Then the spelling: the canonical type, then the other spelling (the - * at-rest tolerance the reads have always had, #4432). - * - Then the package (ADR-0048 prefer-local): with a `packageId`, that - * package's own row, then the package-less row, never another package's; - * with none, any row. - * - [#28409, ADR-0146 D8 item 2] …and, for a read that serves the package's - * name (`servedOverride`), the package's declared override FIRST: the row - * of its paired `.custom` package that declares - * `overrides.package` = the package ({@link isServedOverrideRowOf}), ahead - * of the package's own row and of the package-less row. Ahead of the - * package-less row because a package-less save of such an item writes the - * pair's row whenever it is the item's only binding - * ({@link ObjectStackProtocolImplementation.packagelessAddress}, unchanged): - * serving the package-less row over it would answer a save with a body - * the save never wrote (#23165's shape). A package-unbound row still wins - * over the code package and over another package's row (ADR-0146 D8 - * item 1) until the migration moves it into the pair (D11). - * - * Both readers of stored content take this order, so they cannot pick - * differently: {@link ObjectStackProtocolImplementation.findServedOverlayRow} - * (`getMetaItem`, its draft-preview arm, `getMetaItemLayered`) asks the store - * candidate by candidate, and {@link mergePackageAwareOverlay} (the list's - * active and draft-preview merges, a list scoped to a package included, whose - * package-less rows enter as stand-ins, #21817) asks the rows it already read. Before - * #21804 the list took the LATEST of a package's row and the package-less row - * in row order, so in one order the list served the package-less body for a - * package whose by-name read served the package's own row. - * - * It decides content only. The lock is selected separately, from every row in - * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). - */ -export function servedOverlayRowCandidates(address: { - /** - * [#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; - /** - * [#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; -}): ServedOverlayRowCandidate[] { - const base = address.packageId; - const pair = address.servedOverride === true && typeof base === 'string' && base !== '' - ? customizationPackageId(base) - : undefined; - const places: ReadonlyArray> = base === null - ? [{ packageId: null }] - : base - ? [ - ...(pair?.ok ? [{ packageId: pair.value, overrideOf: base }] : []), - { packageId: base }, - { packageId: null }, - ] - : [{ packageId: undefined }]; - return (['canonical', 'other'] as const).flatMap((spelling) => - places.map((place) => ({ spelling, ...place }))); -} - -/** - * [#24371] Whether a stored row read by - * {@link ObjectStackProtocolImplementation.readItemActiveRows} is one the - * store would answer for `where`, an equality `where` of the shape the - * by-name read's selections send ({@link servedOverlayRowCandidates}' - * candidates, the package-less binding and the lock's scope): every key equal, - * and `null` matching an absent value as the store's IS NULL does. - */ -export function rowMatchesOverlayWhere(row: object, where: Readonly>): boolean { - const stored = row as Readonly>; - return Object.entries(where).every(([key, value]) => (stored[key] ?? null) === value); -} - -/** - * [#21967, ADR-0048] The package dimension of - * {@link servedOverlayRowCandidates}, in its order: with a package, that - * package (`packageId`) and then the package-less rows (`null`); with none, - * `undefined`, which matches a row of any package. - */ -export function addressPackages(packageId: string | undefined): ReadonlyArray { - return [...new Set(servedOverlayRowCandidates({ packageId }) - .map((candidate) => candidate.packageId))]; -} - -/** - * [#21967, ADR-0048] The stored view container expansion that serves `name` - * at the address of `packageId`, among `expansions` - * ({@link ObjectStackProtocolImplementation.expandStoredViewContainers}, each - * paired with the container row it came from); `undefined` when none does. - * - * The package order is the package dimension of - * {@link servedOverlayRowCandidates}, the one prefer-local resolution: with a - * package, the expansion of that package's own container row first, then the - * expansion of a package-less one, which stands in for every package with no - * container row of its own, and never another package's; with none, any - * expansion. Within one package the last expansion of the name wins, as - * before (two containers of one package that expand one name are refused at - * the save door). - * - * Both doors select through this function: the list read - * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each - * package's slot of the name, and the by-name read - * ({@link ObjectStackProtocolImplementation.resolveRowlessExpandedView}) for - * the package it names. So a package's slot in the list and the by-name read - * naming that package serve the same expansion. Before, the list upserted the - * last expansion of a name over every package's item of it, so one package's - * stored copy of a container displaced another package's item of each name - * the copy expands, a withdrawn public form included. - */ -export function servedViewExpansion( - expansions: ReadonlyArray<{ item: Record; container: E }>, - name: string, - packageId: string | undefined, -): { item: Record; container: E } | undefined { - for (const pkg of addressPackages(packageId)) { - let served: { item: Record; container: E } | undefined; - for (const expanded of expansions) { - if (expanded.item.name !== name) continue; - if (pkg === undefined || (expanded.container.packageId ?? null) === pkg) served = expanded; - } - if (served !== undefined) return served; - } - return undefined; -} - -/** - * ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list. - * - * `baseItems` (the lower layer: registry artifacts, or the running result) and - * `records` (the higher layer: active `sys_metadata` overlays, or draft rows) - * are merged so that: - * - * • Two installed packages shipping the same `type/name` stay TWO rows — - * resolution is per `(package, name)`, not bare `name`, so a higher-layer - * row no longer collapses a same-name row from a different package. - * • For each package `P` that owns a row of a given name, the winner is - * `P`'s own row or a package-less ("global", `package_id IS NULL`) row, - * the higher layer winning — `getMetaItem(name, packageId=P)`'s - * "scoped-then-global-fallback" resolution, so the list and single-item - * paths agree. This is also why a legacy row whose active/draft layers - * disagree on package attribution still collapses (a package-less active - * row + its `package_id`-bearing draft resolve to the one package slot, - * draft winning). - * • [#21804] When `records` are stored rows the caller read (each record - * carrying its `stored` place), the winner among them is the one - * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before - * the package-less one. It - * is the resolution `getMetaItem` takes, so it does not depend on the - * order the store returned the rows in. Records with no `stored` place - * (the MetadataService merge, whose `records` are already-resolved items) - * leave the LATEST contribution the winner, as before. - * • A name with NO package-owned row resolves to its latest package-less - * contribution — the pre-existing env-wide behaviour, unchanged. - * • [#21817] A list scoped to one package passes the package-less rows in - * its scope as STAND-INS (`standIn` on a record). A stand-in serves a - * slot the package seats, by the same served-row resolution, so the - * scoped list's slot is what `getMetaItem` naming the package serves: - * the package's own row, else the package-less row. A stand-in never - * seats a slot itself, so the scoped list still lists only the items the - * package ships. A slot that only stand-ins reach is emitted with its - * latest stand-in and recorded in `unseated`, and an item recorded there - * that arrives as a base item counts as a stand-in again: a later layer - * (the draft preview, the MetadataService listing) may still seat that - * slot, and the caller drops what is still recorded once the last layer - * has merged. - * - * `transform(data, prev)` runs on each `records` body before it enters the - * merge (view-identity healing, draft tagging); `prev` is the base row it - * shadows at the same slot (or any same-name base row), else undefined. - * - * [#7774] The bucket is per SLOT, not per name, and for a type in - * `ITEM_KEY_DISCRIMINATORS` the slot is `(name, discriminator)`. This is the - * half of the collapse that #7774's card predicted would need no change, and - * it needed one: the card reasoned about the OVERLAY rows (correctly — they - * are unique on `type+name+organization_id+package_id` and carry no locale - * column), but the rows are only the higher layer. `baseItems` is the - * SchemaRegistry's listing, and since #7730 that listing carries every member - * of a bundle. Bucketed by bare name, two members of one bundle landed in one - * bucket and the loop below emitted exactly one row per `(bucket, package)` — - * so a single unrelated overlay row for the type was enough to drop a locale, - * and the surviving row was the overlay body regardless of which member it - * actually customizes. Slot-keyed, the overlay lands on its OWN member and the - * others are served untouched. An undiscriminated type's slot is its name, so - * its buckets are unchanged. - * - * @param type Canonical (singular) metadata type of every row being merged. - * @param unseated [#21817] The scoped list's record of stand-ins no - * contribution has seated yet (see the stand-in bullet above). - */ -export function mergePackageAwareOverlay( - type: string, - baseItems: unknown[], - 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; - } = {}, -): unknown[] { - // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. - // `stored` marks a contribution that is a stored row the caller read; - // `standIn` one that serves a slot without seating it ([#21817]). - type Contribution = { pkg: string | undefined; item: any; stored?: StoredRowPlace; standIn?: true }; - const buckets = new Map(); - const order: string[] = []; // first-seen slot order → stable output - const slotOf = (item: unknown, name: unknown): string => { - const disc = itemDiscriminator(type, item); - return disc === undefined ? String(name) : `${String(name)}\u0000${disc}`; - }; - const push = (slot: string, pkg: string | undefined, item: any, stored?: StoredRowPlace, standIn?: boolean) => { - let list = buckets.get(slot); - if (!list) { buckets.set(slot, (list = [])); order.push(slot); } - const c: Contribution = stored ? { pkg, item, stored } : { pkg, item }; - if (standIn) c.standIn = true; - list.push(c); - }; - // [#21804] The stored row the served-row resolution picks for package - // `real` among a slot's contributions: the first candidate of - // {@link servedOverlayRowCandidates} that one of the rows sits at. - // [#28409] …and says when that row is `real`'s declared override, which - // the slot then serves under `real`'s provenance. - const servedStoredRow = ( - list: readonly Contribution[], - real: string | undefined, - ): { item: any; overrideOf?: string } | undefined => { - for (const candidate of servedOverlayRowCandidates({ packageId: real, servedOverride: options.servedOverride })) { - const hit = list.find((c) => c.stored !== undefined - && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling - && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId) - // [#28409] The pair's row holds the override candidate only - // when it declares the override: judged on the row's own - // binding (the contribution's `pkg`, the row's `package_id`) - // 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, - ))); - if (hit) return candidate.overrideOf === undefined ? { item: hit.item } : { item: hit.item, overrideOf: candidate.overrideOf }; - } - 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; - 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; - 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, bodyName); - const list = buckets.get(slot); - const prev = list - ? (list.find((c) => c.pkg === packageId)?.item - ?? list.find((c) => c.pkg === undefined)?.item - ?? list[0]?.item) - : undefined; - // [#28673] A stored row enters under its own binding, the `package_id` - // column it is slotted by ({@link withRowBinding}), so a stamp its body - // carries is never served, here or where the row stands in for a - // package's slot below. A record that is not a stored row (a caller's - // item, slotted by its own `_packageId`) enters as it is. - const bound = stored ? withRowBinding(body, packageId) : body; - push(slot, packageId, transform ? transform(bound, prev) : bound, stored, standIn); - } - - const out: unknown[] = []; - for (const slot of order) { - const list = buckets.get(slot)!; - // [#21817] Only stand-ins reach this slot: no item of the package is - // here (yet), so the stand-in is held back, not served as one. - if (list.every((c) => c.standIn)) { - // The stand-in the one candidate order picks among the stored - // rows here, else (no stored row: a stand-in held back by an - // earlier merge) the latest. - let held = servedStoredRow(list, undefined)?.item; - if (held === undefined) held = list[list.length - 1].item; - unseated?.add(held); - out.push(held); - continue; - } - // [#21817, #28409] A stand-in never seats a slot, whatever package it - // is bound to: a package-less row has none, and a pair's row that - // stands in for its base's declared override is bound to the pair. - const reals = Array.from(new Set(list.filter((c) => c.pkg !== undefined && !c.standIn).map((c) => c.pkg))); - if (reals.length === 0) { - out.push(list[list.length - 1].item); // latest package-less row wins - continue; - } - for (const real of reals) { - // getMetaItem(name, real) resolution. [#21804] A stored row is - // chosen by the served-row resolution, whatever order the rows - // came back in; with no stored row of `real`'s own or package-less, - // the latest lower-layer contribution of either stands, as before. - const served = servedStoredRow(list, real); - let chosen: any = served?.item; - if (chosen === undefined) { - for (const c of list) { - if ((c.pkg === real || c.pkg === undefined) && c.stored === undefined) chosen = c.item; - } - } - if (chosen === undefined) continue; - // A package-less body standing in for package `real` must carry - // `real`'s provenance (the base row it replaced was `real`'s). - // [#28409] So must `real`'s declared override: it is served as - // `real`'s item, under `real`'s envelope, wherever its row is bound. - if (served?.overrideOf !== undefined) chosen = { ...chosen, _packageId: real }; - else if (chosen._packageId === undefined) chosen = { ...chosen, _packageId: real }; - out.push(chosen); - } - } - return out; -} - -/** - * Simple hash function for ETag generation (browser-compatible) - * Uses a basic hash algorithm instead of crypto.createHash - */ -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(16); -} - -/** - * Thrown by `updateData` / `deleteData` when the caller supplies an - * `expectedVersion` that does not match the current record's `updated_at`. - * - * The HTTP layer maps this to `409 Conflict` with code `CONCURRENT_UPDATE`, - * and includes both the current server-side version and the current record - * payload so the client can render an informed conflict-resolution UI - * ("Reload latest" vs. "Overwrite anyway"). The record is the row as the - * caller reads it, and a row the caller cannot read is never a conflict: it - * answers not-found instead (#23586, see `assertVersionOf`). - * - * NOTE: This is an *application-level* compare-and-set — not an atomic - * storage-layer CAS. There is a small TOCTOU window between the version - * check and the subsequent write. For the conflict frequency this targets - * (different users seconds-to-minutes apart in B2B record editing) this - * is more than adequate; a future revision can push the check into the - * driver's UPDATE statement (`WHERE id=? AND updated_at=?`) for true - * atomicity. - */ -export class ConcurrentUpdateError extends Error { - readonly code = 'CONCURRENT_UPDATE'; - readonly status = 409; - readonly currentVersion: string | null; - readonly currentRecord: unknown; - constructor(opts: { currentVersion: string | null; currentRecord: unknown; message?: string }) { - super(opts.message ?? 'Record was modified by another user'); - this.name = 'ConcurrentUpdateError'; - this.currentVersion = opts.currentVersion; - this.currentRecord = opts.currentRecord; - } -} - -/** - * [#13576] Thrown when a caller-supplied `expectedVersion` — the `If-Match` - * header or the body/query `expectedVersion` field — is a syntactically legal - * but semantically empty RFC-7232 entity-tag: the quoted-empty token `""`. - * - * ## Why this is refused rather than silently treated as "no token" (the - * pre-#13576 behaviour) or silently treated as a conflict (409) - * - * `""` is valid `entity-tag` grammar (RFC 7232 §2.3: `weak? DQUOTE *etagc - * DQUOTE`, and `*etagc` — zero or more — permits an EMPTY opaque-tag). But an - * empty tag can never equal any stored `updated_at`, quoted or not: it is not - * "the version I read", it is nothing. A client that sends it is asking for a - * guarded write and could not possibly have a real version in hand — RFC - * legality does not make it a meaningful concurrency token, and the platform - * makes an explicit contract choice to treat the shape itself as a client - * defect. Before this it fell through {@link normaliseVersionToken}'s - * quote-strip into the same falsy `''` that "caller sent nothing" produces, - * so the OPPOSITE of what `If-Match` requests happened: the guard was - * SKIPPED rather than evaluated, silently, on the one token shape that opts - * OUT of the check instead of failing it (unlike a garbage-but-nonempty token - * such as `v2`, which still normalises to a real, comparable token and fails - * toward `409 CONCURRENT_UPDATE` — the safe direction for a concurrency - * primitive, and unchanged by this fix). - * - * A `409` here (the alternative the maintainer ruling rejected, decision #20 - * ①, 2026-08-31) would have been safer than the pre-#13576 behaviour but - * would still have collapsed two different facts into one answer: "you lost a - * race" (409, actionable by reloading and retrying) and "you sent something - * that can never carry a version" (this — a client-side bug, not a race). A - * `400` keeps that distinction legible, which is the entire reason option 3 - * was chosen over option 2. - * - * `VALIDATION_FAILED`/400 is this package's own house code for a - * caller-request defect the engine never has to touch (mirrors - * {@link rowRequiredIdError}) — already registered in the ADR-0112 - * error-code ledger under `@objectstack/metadata-protocol`, so this needs no - * new ledger entry. - */ -export class MalformedVersionTokenError extends Error { - readonly code = 'VALIDATION_FAILED'; - readonly status = 400; - readonly fields: never[] = []; - constructor() { - super( - 'expectedVersion (If-Match) is the empty entity-tag `""`. An empty version ' - + 'token can never match any stored version, so this is almost certainly a ' - + 'client defect rather than a real concurrency check — send the real version ' - + 'token you read (e.g. the record\'s `updated_at`), or omit If-Match / ' - + 'expectedVersion entirely to perform an unguarded write.', - ); - this.name = 'MalformedVersionTokenError'; - } -} - -/** - * An ISO-8601 date-time that denotes an ABSOLUTE instant — it carries an - * explicit `Z` or a numeric `±HH:mm` offset, so reading it never consults the - * process timezone. - * - * Deliberately narrower than a bare `Date.parse`, which falls back to - * implementation-specific heuristics outside this shape and reads a zone-LESS - * date-time as *local* time — the one thing an OCC verdict must never depend on. - * A string this pattern rejects is not reinterpreted: it stays an opaque token - * and is compared verbatim, exactly as before. - */ -const ABSOLUTE_ISO_INSTANT = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; - -/** Largest magnitude a JS time value may hold; `toISOString()` throws beyond it. */ -const MAX_TIME_VALUE = 8.64e15; - -/** - * The canonical absolute-instant spelling of a version token, or null when the - * token does not denote an instant. - * - * The input domain is the set of things a DRIVER puts in `updated_at`, measured - * rather than listed from memory (#13382): - * - * - **JS `Date`** — `driver-sql` on Postgres and MySQL (`timestamptz` / - * `DATETIME(3)` are instants, and the driver materialises them as `Date` on - * purpose; `SqlDriver.withPostgresCalendarDayAsText` says so in as many - * words) and `driver-mongodb` (it stamps `new Date()`, and BSON round-trips - * it). Canonical form: `toISOString()`. - * - **`string`, already canonical ISO-8601 UTC with milliseconds** — - * `driver-sql` on SQLite, its `driver-turso` / `driver-sqlite-wasm` siblings, - * and `driver-memory`. Canonical form: itself, re-derived through `Date`. - * - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated SQLite - * column. Reachable: the driver's legacy datetime repair is keyed on declared - * `Field.datetime` columns, and the engine-injected audit columns are not in - * that set, so such a value passes through unrepaired. Canonical form: - * `new Date(ms).toISOString()`. - * - **`null` / `undefined`** — timestamps disabled, or no such column. No - * check runs at all; handled by the caller, not here. - * - **anything else** a host stamps into the column — opaque, and compared - * verbatim rather than guessed at. - * - * Milliseconds are the resolution, because that is what a JS `Date` — and so - * every driver's materialised instant — can carry. A token with more fractional - * digits (a Postgres microsecond rendering, say) truncates to the same - * millisecond as the record it is compared against, which is the point: two - * spellings of one instant must not conflict. - */ -export function canonicalVersionInstant(value: unknown, token: string): string | null { - let ms: number; - if (value instanceof Date) { - ms = value.getTime(); - } else if (typeof value === 'number') { - ms = value; - } else if (ABSOLUTE_ISO_INSTANT.test(token)) { - ms = Date.parse(token); - } else { - return null; - } - if (!Number.isFinite(ms) || Math.abs(ms) > MAX_TIME_VALUE) return null; - return new Date(ms).toISOString(); -} - -/** The verbatim spelling the ADR-0067 timeline compared before #13995 - `String(v ?? '')`. */ -function auditInstantToken(value: unknown): string { - return value === null || value === undefined ? '' : String(value); -} - -/** - * Epoch milliseconds for a DRIVER-stamped audit column, or null when the value - * does not denote an instant. - * - * The input domain is {@link canonicalVersionInstant}'s, and it is delegated to - * rather than re-spelled: that domain (`Date` / epoch `number` / canonical ISO - * text / opaque) was measured off the drivers for #13382 and is the same set a - * `created_at` arrives in. Its canonical answer round-trips through `Date.parse` - * exactly, and it has already bounded the value to a finite, `toISOString`-able - * time, so the number below is always finite. - */ -function auditInstantMs(value: unknown): number | null { - if (value === null || value === undefined) return null; - const canonical = canonicalVersionInstant(value, auditInstantToken(value).trim()); - return canonical === null ? null : Date.parse(canonical); -} - -/** - * Compare two engine-stamped audit instants, OLDEST-FIRST (`< 0` when `a` is - * older than `b`) - the shape both a `sort` comparator and a "strictly newer - * than" filter need. - * - * ## Why this exists (#13995) - * - * `created_at` is an engine-injected audit column: it is not in `datetimeFields`, - * and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`, so - * the live dialects hand it out of the record read door as a JS `Date` and the - * SQLite family as canonical ISO-Z text (pinned by driver-sql's - * `sql-driver-13567-audit-stamp-materialisation.test.ts`). `String(aDate)` is - * `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"`, whose LEADING - * token is the weekday NAME - so a lexicographic compare over those strings - * orders `Fri < Mon < Sat < Sun < Thu < Tue < Wed`, which has nothing to do with - * chronology. It is stable across the whole set, so the failure is systematic - * rather than intermittent: there is never an "it worked once" to warn anyone. - * Both ADR-0067 commit-timeline consumers compared exactly that way, and one of - * them - {@link ObjectStackProtocolImplementation.rollbackToPackageCommit} - - * chooses the set of commits to UNDO with it. - * - * ## Why a sibling of {@link canonicalVersionInstant} rather than a reuse of - * {@link versionTokensAgree} - * - * The canonicalisation IS reused (see {@link auditInstantMs}). What is not is - * `versionTokensAgree`: it answers EQUALITY between two client-facing version - * TOKENS, and ordering needs `<`/`>`, not `===`. It also takes the pre-tidied - * {@link NormalisedVersion} pair an HTTP `If-Match` header produces - RFC-7232 - * quote stripping, the "no token supplied" opt-out - none of which applies to a - * column a driver stamped and no client ever sends. - * - * The opaque limb is deliberately the one `versionTokensAgree` documents: when - * either side does not denote an instant, the two are compared VERBATIM, exactly - * as this seam did before. So the only verdicts that change are pairs that denote - * instants - the defect - and nothing a host stamps into the column starts - * ordering differently. This is not a tolerant fallback over a mis-spelled key - * (#13973's standing prohibition): no alternative spelling is accepted anywhere - * here, and an unparseable value is not repaired, only left where it was. - */ -function compareAuditInstants(a: unknown, b: unknown): number { - const aMs = auditInstantMs(a); - const bMs = auditInstantMs(b); - if (aMs !== null && bMs !== null) { - return aMs < bMs ? -1 : aMs > bMs ? 1 : 0; - } - const aToken = auditInstantToken(a); - const bToken = auditInstantToken(b); - return aToken < bToken ? -1 : aToken > bToken ? 1 : 0; -} - -/** - * Canonicalise the ONE driver materialisation {@link listCommits} was - * measured to produce for `sys_metadata_commit.created_at` — a valid JS - * `Date` — into the ISO-8601 string the return type declares (`createdAt?: - * string`). Every other shape, INCLUDING an Invalid `Date`, is returned - * UNTOUCHED. - * - * [#14038] `created_at` is an engine-injected audit column: it is not in - * `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its - * `if (this.isSqlite)` arm (pinned live in driver-sql's - * `sql-driver-13567-audit-stamp-materialisation.test.ts`), so Postgres and - * MySQL hand it out of the record read door as a JS `Date` while the - * mapping below assigned it straight through as `r.created_at` — an - * unchecked value from an `any[]` row, never a measurement against the - * declared `string` return type. - * - * ⚠️ Deliberately NOT the `canonicalIsoInstant` spelling next door in - * `sys-metadata-repository.ts` / `database-loader.ts` — which is, since - * #16422, the ONLY spelling at #14037's sibling sites. That difference used - * to be exactly one input shape — the Invalid - * `Date` on which that spelling raised `RangeError: Invalid time value`, - * measured reachable on BOTH live dialects (a MySQL zero datetime; any - * Postgres year in 275760..294276). #14078 has since RULED it (option B, - * 2026-09-02): that arm is now total and answers `undefined` for the shape. - * - * ⛔ They are still not ONE spelling, and **#16422 ruled that this copy is the - * one that stays**. That card collapsed the family's other four call sites — - * `rowToEvent` in `sys-metadata-repository.ts` and the three adapter - * boundaries in `database-loader.ts` — into `canonicalIsoInstant` and deleted - * both sibling definitions of this spelling. This site was held out, for the - * reason the last paragraph below states: `listCommits` promises its callers - * the RAW value back for a non-`Date`, and `canonicalIsoInstant` rewrites the - * whole domain. Measured on the seven inputs that distinguish the two - * helpers, swapping it in here moves three: an Invalid `Date` would be ERASED - * from the response (`undefined` — the one answer [ADR-0053 D-F3] refuses, - * because it silently drops a value that is on disk), and a `number` and an - * opaque object would reach {@link compareAuditInstants} as `String(value)` - * rather than verbatim, reordering rows this seam deliberately leaves alone. - * - * ⇒ The family is now two DELIBERATE helpers, not one pending merge: the - * shared domain rewrite at the sites whose declared field is a - * `z.string().datetime()` and whose caller carries a terminal value, and this - * narrow one-shape conversion at the site whose declared contract is - * pass-through. ⛔ Do not collapse it without superseding that ruling. - * - * ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no - * consumer to accept an off-spec shape; it converts the one measured - * producer materialisation at the producer. The - * `!Number.isNaN(value.getTime())` guard is the same one #14037 used for - * its two sibling sites, not a new spelling. - * - * ⛔ Not exported and not merged into {@link canonicalVersionInstant} above: - * that helper answers a different question (does this token denote AN - * instant at all, returning `null` when it does not) and callers of - * `listCommits` are promised the RAW value back untouched when it is not a - * valid `Date` — an absent/opaque column must still reach `sort`'s fallback - * branch and any in-process reader exactly as before. Consolidating the - * family's near-identical copies was expected to be #14078's call; that - * ruling covered only the five arms that threw, and #16422 then ruled this - * promise the reason to keep this copy rather than the obstacle to removing - * it. §D of `protocol-14038-list-commits-created-at-iso.test.ts` is the pin. - */ -function isoFromValidDate(value: unknown): unknown { - if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString(); - return value; -} - -/** - * ADR-0087 conversions the save door's flow fallback verdict (#21982) does - * not replay, by id — the same DEFAULT-FLIP refusal the automation engine's - * canonicalizer makes (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in - * `service-automation/src/engine.ts`), because the fallback stands in for that - * canonicalizer and must reach the same verdict on the same body. - * - * - `flow-decision-mode-inclusive-explicit` (#15429) rewrites an omitted - * decision `mode` into `'inclusive'`, which is sound only where "this body - * predates the flip" is a fact; a save never knows that, exactly as the - * engine's canonicalizer does not. - */ -export const FLOW_FALLBACK_VERDICT_NOT_REPLAYED: readonly string[] = [ - 'flow-decision-mode-inclusive-explicit', -]; - -// Lifecycle columns the engine always owns; the clone path drops them by NAME -// so the insert re-stamps fresh values instead of copying the source's. Mirrors -// record-validator's SKIP_FIELDS (system-injected, never author-supplied). -const CLONE_STRIP_FIELDS: readonly string[] = [ - 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', -]; - -/** - * One row of a bulk-write result — exactly the `BatchOperationResult` the spec - * declares (`BatchOperationResultSchema`, spec/api/batch.zod.ts). Aliased to - * the spec type so tsc pins every batch loop in this file to the wire contract: - * declared = delivered (#4793; the conformance pin in - * protocol.batch-row-conformance.test.ts holds the runtime side). - * - * History: this was a deliberately divergent legacy shape (`error: string`, - * `record`, no `index`) that predated the schema; ADR-0119 D4 left it in place - * so a wire-visible change would not ride along on a bug fix, and #4793 is - * that tracked reconciliation, shipped in the v17 major window. The rollback - * marking (#4620) is structured with it: `ROLLED_BACK` / `NOT_ATTEMPTED` are - * `ApiError.code` values, not message-string prefixes. - */ -export type BatchDataRowResult = BatchOperationResult; - -/** - * Map a thrown per-row batch error into the wire's `ApiError` (#4793). - * - * `code` keeps the thrown error's own code when it is part of the declared - * vocabulary (StandardErrorCode ∪ ERROR_CODE_LEDGER — an invented code would - * make the row fail `BatchOperationResultSchema.parse`, exactly the drift the - * conformance pin exists to catch loudly rather than ship). Otherwise it - * derives from the HTTP status when the error carries one, falling back to - * INTERNAL_ERROR — an unclassified engine throw is a 500 in row form. - * - * `message` is {@link clientFacingRowFailureText}'s decision (#8502): a caught - * sentence reaches a caller only when its producer declared a client-facing - * refusal. `fallback` is what the row says otherwise — it names the operation - * and quotes nothing. - * - * ⛔ `fallback` is REQUIRED, not defaulted. All three catches that build a row - * know their operation, and a default would let a fourth one be added that - * silently reports the wrong verb. - * - * ## `httpStatus` reads the DECLARATION, not one spelling of it (#8570) - * - * This limb read `err.status` and nothing else, so of the producers measured at - * these catches — a real `ObjectQL` over a real `SqlDriver`, driven through all - * three bulk-write loops — two well-defined client refusals shipped a row with - * no status at all: - * - * | producer | code | `.status` | `.statusCode` | validation shape | was | is | - * |---|---|---|---|---|---|---| - * | {@link rowRequiredIdError} | VALIDATION_FAILED | **400** | — | no | 400 | 400 | - * | `recordNotFoundError` (`@objectstack/core`) | RECORD_NOT_FOUND | **404** | — | no | 404 | 404 | - * | objectql `ValidationError` | VALIDATION_FAILED | — | — | **yes** | — | **400** | - * | plugin-approvals' record lock | RECORD_LOCKED | — | **409** | no | — | **409** | - * | an app hook throwing a bare `Error` | — | — | — | no | — | — | - * | driver fault (`SqliteError`, …) | SQLITE_* | — | — | no | — | — | - * - * The first two are siblings of the last four *in the same response*: a caller - * branching on `httpStatus` to tell "fix your input" from "the server broke" - * got an answer for some failure rows and nothing for others, with no signal - * saying which. The single-spelling defect is #7525's, fixed there at the HTTP - * door; this is the same defect on the row. - * - * The question is answered by {@link resolveThrownHttpError}, IMPORTED — the - * `message` limb beside it already delegates there (#8502), and a second local - * chain would be the third derivation of "what status is this throw" in one - * function. ⛔ Do not re-spell it as `status ?? statusCode ?? validation`. - * - * ## ⛔ `declaredStatus`, never `status` — the over-broad direction is real - * - * That resolver answers for EVERY throw: its `status` is 500 for a bare hook - * `Error` and for a `SqliteError`, because 500 is the caller's fallback. - * Stamping that would put `httpStatus: 500` on the last two rows of the table, - * which never carried one — an ADDITION to the wire for those populations, and - * a claim the producer never made. `declaredStatus` is the same resolution - * minus the fallback: present exactly when the throw declared a status in one - * of the three spellings, absent otherwise. So a declared refusal gains the - * status it always meant, and an undeclared fault keeps carrying none. - * - * The gate is DECLARED-ness and deliberately not the 4xx band that - * {@link clientFacingRowFailureText} uses. That limb decides disclosure of free - * text, where a 5xx must be withheld; this one decides a number the producer - * itself authored, and a row already ships `httpStatus: 503` today when the - * refusal spells `.status` — narrowing to 4xx would WITHDRAW a status the wire - * carries, which is a different decision from this one. - * - * Both limbs read the same resolution for a second reason: they must agree. - * Deriving `code` from `err.status` while `httpStatus` came from - * `declaredStatus` would mint incoherent rows — `{ code: 'INTERNAL_ERROR', - * httpStatus: 409 }` for a `statusCode`-spelled refusal whose own code the - * ledger does not know. - * - * ## One wire spelling for a unique-constraint refusal (commit 65846bc46) - * - * The engine answers a driver's unique-constraint refusal with its - * `DuplicateRecordError` envelope — `code: 'DUPLICATE_RECORD'`, `status: 409`, - * the driver's error whole on `cause`. `DUPLICATE_RECORD` is a registered - * member, so the verbatim limb above used to put it on the row as-is, while - * every WHOLE-REQUEST door in `@objectstack/rest` answers the same class as - * `UNIQUE_VIOLATION` — the standard-catalog member the published protocol - * docs give for the 409 constraint-violation body. After commit 6d178a408 the two - * spellings sat side by side in one route's responses: a whole-request - * failure on `POST /data/:object/batch` said `UNIQUE_VIOLATION`, a row on the - * same route said `DUPLICATE_RECORD`. - * - * Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire spelling on every route, - * `UNIQUE_VIOLATION`; the row reports it too. So this limb maps the engine's - * envelope to the wire spelling BEFORE the verbatim registered-code rule — - * keyed exactly as the whole-request arm keys it (`error-response.ts`'s - * `structuredCodeAnswer`: the registered code AND the class name), never by - * message text. `httpStatus` still reads the declared 409 and `message` is - * still the envelope's platform sentence (a declared 4xx, so it is quoted); the - * driver's text stays on `cause`, which never reaches the row. - * - * ⛔ The engine's thrown identity is NOT renamed: `DuplicateRecordError.code` - * stays `DUPLICATE_RECORD` (an in-process caller — a hook, a flow node — - * branches on that). What moves is what a ROW REPORTS across the HTTP - * boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook - * throwing the registered code without being the engine's class — keeps its - * own code on the row, the same discrimination the whole-request arm makes. - * - * ## [#24561] A sandboxed body's refusal is not a server fault in row form - * - * The `INTERNAL_ERROR` default assumes an error that declared nothing is a - * fault. A sandboxed hook body's bare `throw new Error('…')` declares no status - * or code either, yet every data door (`/data`, `/createMany`, `/import`) - * serves it as a `400` refusal in the body's words, so a batch row said - * `INTERNAL_ERROR` beside the doors' `VALIDATION_ERROR` for the same refusal. - * {@link sandboxRefusalSentence} is the one test both limbs ask: when it - * answers, the `code` limb derives from the `400` the doors serve (the - * `declared ?? 400` of `classifyDataError`, `@objectstack/types`). A code or - * status the body DID declare still wins, as for every other producer. - * - * ⛔ `httpStatus` is NOT stamped for it. That limb carries only a status the - * producer declared (above), and this body declared none. - */ -export function toRowApiError(err: any, fallback: string): ApiError { - const thrown = isEngineDuplicateRecordEnvelope(err) - ? 'UNIQUE_VIOLATION' - : typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success - ? (err.code as ApiError['code']) - : undefined; - const { declaredStatus } = resolveThrownHttpError(err); - const servedStatus = declaredStatus ?? (sandboxRefusalSentence(err) !== undefined ? 400 : undefined); - return { - code: thrown ?? (servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus) : 'INTERNAL_ERROR'), - message: clientFacingRowFailureText(err, fallback), - ...(declaredStatus !== undefined ? { httpStatus: declaredStatus } : {}), - }; -} - -/** - * [commit 65846bc46] Is this thrown value the ENGINE's unique-violation envelope? - * - * The same two-part gate `@objectstack/rest`'s whole-request arm applies — - * the registered code AND the class name — so a batch row and a whole-request - * failure recognise the engine's envelope by one rule. Name alone would miss - * nothing today but would let a future class of the same name speak a - * different code; code alone would swallow a hook that deliberately throws - * the registered `DUPLICATE_RECORD` from its own body, which is a different - * producer speaking a member of the vocabulary and keeps its own spelling. - * The class itself is not imported: this package does not depend on - * `@objectstack/objectql`, and the envelope's contract is its declared - * `code` / `name` / `status`, not its prototype. - */ -function isEngineDuplicateRecordEnvelope(err: unknown): boolean { - const e = err as { code?: unknown; name?: unknown } | null | undefined; - return e?.code === 'DUPLICATE_RECORD' && e?.name === 'DuplicateRecordError'; -} - -/** - * [#7426] Carry a wrapped error's `code` onto its re-wrap — but only when that - * code is part of the DECLARED vocabulary. - * - * ## What it fixes - * - * {@link ObjectStackProtocolImplementation.deleteMetaItem} is the one verb in - * this file that re-wraps rather than rethrows: its two catches build a fresh - * `Error` carrying the "failed to delete" context, and they carried `status` - * forward while dropping `code`. Every sibling verb translates `ConflictError` - * and then `throw err`s the original untouched, so `code` survives there. The - * consequence was topology-dependent: a `SysMetadataRepository` refusal — the - * only refusal a CONTROL-PLANE kernel can produce for these types, since - * `deleteMetaItem`'s own two-tier block is skipped when `environmentId` is - * undefined — reached the caller as **403 with `code: undefined`**, its code - * surviving only as prose inside the message, while the identical refusal on a - * project kernel arrived with `NOT_OVERRIDABLE` intact. ADR-0112 makes the code - * the machine-readable half of a refusal; a 403 without one is what the ledger - * exists to prevent. - * - * ## Why it is a PREDICATE and not `e.code = err.code` - * - * That re-wrap is the exit of every non-conflict failure on the path — genuine - * driver faults as much as refusals — so an unconditional copy would put a - * driver's own dialect (`42P01`, `SQLITE_CONSTRAINT`, `ECONNREFUSED`) into the - * field `ApiErrorSchema.code` declares as a closed union (ADR-0112 D4), which - * is the drift the ledger exists to stop. The gate is therefore membership in - * `StandardErrorCode ∪ ERROR_CODE_LEDGER` — verbatim the predicate - * {@link toRowApiError} above already applies to decide which thrown code may - * become a wire code. One rule, one place to change it, and a code the engine - * DID register (`ERR_DATASOURCE_UNAVAILABLE`) is as welcome as one this package - * threw. - * - * ## What it deliberately does not touch - * - * `status`. The repository catch's `err?.status ?? 500` and the legacy catch's - * literal `500` are unchanged — the second is a path with no authorization gate - * on it, where every failure really is a fault, and moving either is a separate - * contract decision. Only the `code` rule is unified across the two exits, so - * the envelope does not vary by which failure kind produced it. - */ -function carryCatalogedErrorCode(target: Error, source: unknown): void { - const code = (source as { code?: unknown } | null | undefined)?.code; - if (typeof code === 'string' && ErrorCode.safeParse(code).success) { - (target as Error & { code?: string }).code = code; - } -} - -/** - * [#8136] Whether a caught error **declared itself a client-facing refusal** — - * a 4xx `status` in the ADR-0112 envelope — and its sentence may therefore be - * quoted back to the caller. - * - * ## The rule this answers, and why it is a POSITIVE list - * - * This package used to interpolate whatever it caught into the message it - * threw (`Failed to delete customization overlay: ${err.message}`) and into the - * `error` strings it collects for a caller. A driver failure on `sys_metadata` - * therefore reached a client verbatim — `SQLITE_ERROR: no such table: - * sys_metadata` on a 500, and the same text inside `failed[].error` on the - * `PACKAGE_DELETE_PARTIAL` 400, where no message-level withhold at any HTTP - * boundary can reach it because it is not the message. Three downstream - * sanitizers each had a hole because of it. - * - * The cure is Prime Directive #12's: pay the consumer-side tolerance down at - * the producer. So the question asked here is **not** "does this text look like - * a driver dump?" — that is `looksLikeInternalErrorLeak`, a heuristic over - * phrasing, and a phrasing test can only ever know the dialects someone has - * met. It is the inverse and bounded question: **did we author this sentence - * for a caller?** A producer that declared 4xx has said the failure is the - * caller's to fix and has written the remedy into the message — the - * self-correcting refusals `SysMetadataRepository` raises (`ITEM_LOCKED`, - * `WRITABLE_PACKAGE_REQUIRED`, `NO_DRAFT`, …) are exactly that, and they - * must survive intact. Everything else is withheld by DEFAULT, so a dialect - * this repo has never run is handled correctly without anyone having enumerated - * it. - * - * ## ⛔ NOT the complement of `declaresServerFault` - * - * The obvious-looking `!declaresServerFault(err)` (`@objectstack/types`, #5811) - * is the wrong direction and would reinstate the whole defect: a bare `Error` - * from a driver declares NOTHING, so it fails that test and would be quoted — - * and a bare driver `Error` is precisely the case measured here. The two - * predicates answer different halves and neither is the other's negation: - * `declaresServerFault` asks a BOUNDARY whether to withhold a declared fault's - * detail; this asks a PRODUCER whether it is allowed to quote at all, and an - * undeclared error is never allowed. - * - * ## Why `status` alone, when the sibling code rule also checks the catalog - * - * {@link carryCatalogedErrorCode} gates on membership in `StandardErrorCode ∪ - * ERROR_CODE_LEDGER` because it writes `ApiErrorSchema.code`, a closed union a - * driver's own dialect must never enter. A message is free text, so the - * catalog does not bound it, and the two readings coincide on every refusal - * that reaches these exits today (each declares both halves). Where they could - * differ, status-alone is the safe direction: requiring a catalogued code too - * would blank an authored refusal that happens to carry an uncatalogued one — - * deleting a #4277 self-correcting message, which is a usability regression in - * exchange for no disclosure gain. - * - * The withheld text never leaves the server: every call site rides the original - * error on `cause`, which `handleRouteError` / `logWithheldServerFault` print - * whole — the same posture {@link metadataStoreUnavailableError} already takes. - */ -export function declaresClientRefusal(err: unknown): boolean { - const status = (err as { status?: unknown } | null | undefined)?.status; - return typeof status === 'number' && status >= 400 && status < 500; -} - -/** - * [#8136] The client-facing sentence for a failed overlay delete: the caller's - * own refusal when they declared one, and otherwise a stable line that names - * the operation and quotes nothing. - * - * Both of {@link ObjectStackProtocolImplementation.deleteMetaItem}'s re-wrap - * exits share it, for the reason {@link carryCatalogedErrorCode} gives about - * `code`: the envelope must not vary by which path served the delete. - * - * The `Failed to delete customization overlay` prefix is unchanged, byte for - * byte — it is the operation description a caller needs and several pins read - * it. What changes is what follows the colon. - */ -function overlayDeleteFailureMessage(err: unknown, type: string, name: string): string { - if (declaresClientRefusal(err)) { - const declared = (err as { message?: unknown } | null | undefined)?.message; - if (typeof declared === 'string' && declared.length > 0) { - return `Failed to delete customization overlay: ${declared}`; - } - } - return `Failed to delete customization overlay for ${type}/${name}. ` - + 'The metadata store rejected the delete; the reason is in the server log. ' - + 'Retry once the metadata database is reachable.'; -} - -/** - * [#8136] The per-item `error` string a collector puts on its response — the - * caller's own refusal when they declared one, otherwise a stable fallback. - * - * The counterpart of {@link overlayDeleteFailureMessage} for the paths that - * report failure as DATA rather than by throwing. That distinction is why the - * producer is the only place this can be fixed: `deletePackage`'s `failed[]` - * and `cleanups[]` ride onto a `PACKAGE_DELETE_PARTIAL` 400 inside `details`, - * so no 5xx message withhold at any HTTP boundary ever sees them. - * - * @param fallback - what to say when nothing may be quoted. Already the - * existing no-message fallback at every call site (`'delete failed'`, - * `'cleanup failed'`), so the withheld case reuses the sentence the caller - * could already receive rather than inventing a second vocabulary. - * - * [#8443] EXPORTED (via `index.ts`) for the same reason `recordNotFoundError` - * is: `@objectstack/runtime`'s package-publish door carries a seed-apply - * fallback for protocols that do not self-apply, and it reports failure as - * data on the same `seedApplied` field this package's `applySeedBodies` does. - * A second private restatement of the rule over there is exactly how the rule - * drifts out of sync — the withhold and the quote must be ONE decision, in one - * place, for both producers. `declaresClientRefusal` stays private on purpose: - * nothing outside this file needs the raw predicate, and an exported surface - * with no consumer is the "declared but nobody pulls it" shape AGENTS.md - * treats as debt. - */ -export function clientFacingFailureText(err: unknown, fallback: string): string { - if (declaresClientRefusal(err)) { - const declared = (err as { message?: unknown } | null | undefined)?.message; - if (typeof declared === 'string' && declared.length > 0) return declared; - } - return fallback; -} - -/** - * [#24561] The sentence a sandboxed hook body's business refusal says on a - * batch row, or `undefined` when this error is not one. - * - * The read is `sandboxBusinessMessage` (`@objectstack/types`), the one every - * sibling door makes (the import runner, `/data`, `/createMany`), never a - * pattern-strip of the wrapper off `.message`. It already declines a body - * that CRASHED and the sandbox's own fault. The one condition added here is - * the doors' own: a refusal that declared a server-band status is answered as - * a fault with its text withheld (`classifyDataError`'s `declared < 500`, read - * through the same `declaredHttpStatus`), so it is not a sentence for a row - * either. - * - * Both limbs of {@link toRowApiError} ask this, so the row's `code` and - * `message` cannot disagree about whether a refusal was disclosed. - */ -function sandboxRefusalSentence(err: unknown): string | undefined { - const sentence = sandboxBusinessMessage(err); - if (sentence === undefined) return undefined; - const declared = declaredHttpStatus(err); - return declared === undefined || declared < 500 ? sentence : undefined; -} - -/** - * [#8502] The per-row `errors[].message` a bulk data write puts on its - * response — the producer's own refusal when it declared one, otherwise a - * stable sentence naming the operation. - * - * The third sink in this family, after #8333's `failed[].error` and #8442's - * seed `errors[].message`, and the same defect: `toRowApiError` interpolated - * `err.message` unconditionally, so a driver fault under `deleteManyData` - * answered `{ code: 'INTERNAL_ERROR', message: 'SQLITE_ERROR: no such table: - * leave_request' }` on a per-row result riding a **200**. No HTTP boundary's - * 5xx withhold reaches it, because it is not the response's message — it is - * response DATA. It also multiplies: `reconcileStoppedBatch` and - * `buildRolledBackBatchResponse` interpolate the causal row's message into - * every `NOT_ATTEMPTED` / `ROLLED_BACK` sibling, so one leaked sentence is - * repeated across the batch. - * - * ## Why this asks the BOUNDARY RESOLVER rather than {@link declaresClientRefusal} - * - * The question is #8136's, unchanged: **did a producer author this sentence - * for a caller?** What differs is the population that answers it. This sink - * sits under `engine.insert` / `update` / `delete`, so it receives every - * refusal the data path can raise — and MEASURED on the real stack (a real - * `ObjectQL` over a real `SqlDriver`, driven through all three loops), that - * population declares itself in THREE spellings, only one of which - * `declaresClientRefusal` reads: - * - * | producer | code | `status` | `statusCode` | validation shape | - * |---|---|---|---|---| - * | {@link rowRequiredIdError} | VALIDATION_FAILED | **400** | — | no | - * | `recordNotFoundError` (`@objectstack/core`) | RECORD_NOT_FOUND | **404** | — | no | - * | objectql `ValidationError` | VALIDATION_FAILED | — | — | **yes** | - * | plugin-approvals' record lock | RECORD_LOCKED | — | **409** | no | - * | an app hook throwing a bare `Error` | — | — | — | no | - * | driver fault (`SqliteError`, …) | SQLITE_* | — | — | no | - * - * The first two are the only ones a `status`-only test admits. The third is - * the population #8442 met on the seed channel: an objectql `ValidationError` - * carries `code: 'VALIDATION_FAILED'` and deliberately NO `status`, because - * per `@objectstack/types`' `validation-failure.ts` deciding it means 400 is - * "the job of whichever boundary serves it". The fourth is this sink's own, - * and neither sibling card met it: `plugin-approvals` binds a GLOBAL - * `beforeUpdate` hook whose `lockedError` throws `statusCode` — the spelling - * `resolveThrownHttpError` reads "because both are produced in this repo", - * after reading only one of them made `/api/v1/data` answer 500 to a - * deliberate `409 RECORD_LOCKED` (#7525). - * - * So the test is not "does it carry `status` 4xx" but **"would the boundary - * that serves this throw call it a client refusal?"** — and that question - * already has exactly one implementation, {@link resolveThrownHttpError}, - * which folds all three declarations into one status. It is IMPORTED, never - * re-spelled: a fourth local spelling of "this is a 4xx" is how a batch row - * and the single-record `PATCH` of the same object would start disagreeing - * about the same error. ⛔ Do not replace this with a hand-written - * `status ?? statusCode ?? validation` chain for being cheaper to read. - * - * ## ⛔ Still a POSITIVE list — the undeclared case is withheld - * - * A hook that throws a bare `Error` has its sentence withheld, and that is the - * measured cost of the rule, not an oversight: at this sink an undeclared hook - * throw is indistinguishable from an undeclared driver throw, which is the - * whole hole. The remedy is at the producer and now has three accepted - * spellings — a 4xx `status`, a 4xx `statusCode`, or the `VALIDATION_FAILED` - * shape `validationFailure()` builds — so declaring is cheaper than the - * workaround, which is the direction that makes authored code hard to get - * wrong. The same reasoning #8333 applied when it fixed P9's undeclared - * `ZodError` at the producer rather than loosening the collector. - * - * ## The operator half - * - * The withheld sentence never leaves the server: it is logged here, at the one - * point where the decision is made, so no call site can withhold without - * logging. `console.warn` and not `error` deliberately — nothing claimed to be - * persisted was silently dropped (the row reports `success: false` and the - * counters reconcile), which is the AGENTS.md judgment question the durability - * levels turn on. - * - * ## [commit 93d2d679b] …and the DISCLOSED row deliberately logs NOTHING - * - * Since #14095 a driver unique violation arrives here already wrapped in the - * engine's `DUPLICATE_RECORD` envelope, which declares `status: 409` — so the - * row is disclosed and this function returns before the `console.warn` above. - * That was filed as a possibly LOST diagnostic: withholding used to be what - * carried the driver's own sentence to an operator, and disclosure removes - * that carrier. - * - * Measured on the real stack rather than reasoned about — a real `SqlDriver` - * over better-sqlite3 through this very sink, in `@objectstack/runtime`'s - * `batch-row-driver-text-real-driver.integration.test.ts` — the sentence is - * NOT lost: the engine's own insert door logs the envelope's `cause` - * (#14095 / commit 9d7f7259f, `e instanceof DuplicateRecordError ? e.cause : e`, - * because the platform logger serializes only `message` and `stack`), so - * `UNIQUE constraint failed: bd_note.email` is in the server log with the - * failing column intact. The diagnostic moved one hop; it was not deleted. - * - * ⛔ So do NOT add a log line to the disclosed branch. It would restate what - * the engine already logged, once per duplicate row of a batch, at a site - * where the failure was handed to the CALLER — the third answer AGENTS.md's - * degradation rule names, which is "not a degradation at all". The invariant - * this function owes is one-directional and is pinned in BOTH directions by - * that integration file: it logs when it WITHHOLDS, and is silent when it - * does not. - * - * ## [#24561] A sandboxed body's refusal is read the way every sibling door reads it - * - * The undeclared hook above is an IN-PROCESS hook. A sandboxed (QuickJS) hook - * body is a different producer: its runner puts the body's own sentence on - * `SandboxError.innerMessage` for the caller and keeps the - * `hook '' threw: …` debug wrapper on `.message` for the log. `/data`, - * `/createMany` and `/import` answer that sentence; this sink withheld it as a - * fault, so `POST /data/:object/batch` alone said "the reason is in the server - * log" for a refusal the hook wrote for the caller. The sentence is now read - * through {@link sandboxRefusalSentence} FIRST, which is - * `sandboxBusinessMessage` (`@objectstack/types`), the import runner's read. - * - * And a sandbox origin's `.message` is never quoted, whatever it declared. - * It is the wrapper, written for the log: a refusal that declared a 4xx used - * to be quoted from there, wrapper and all, and so did a body that CRASHED - * with a declared 4xx, which every data door answers `500` with its text - * withheld. (The runner's own faults never declare a status, so the 4xx - * branch cannot reach them.) What stays withheld is what the doors withhold: - * a crash, the runner's own fault, a refusal that declared a 5xx, and every - * non-sandbox error that declared no 4xx. - */ -function clientFacingRowFailureText(err: unknown, fallback: string): string { - const sentence = sandboxRefusalSentence(err); - if (sentence !== undefined) return sentence; - // 500 as the fallback status: an error that declared nothing is a server - // fault in row form, exactly what `toRowApiError`'s own code limb assumes. - const { status } = resolveThrownHttpError(err, 500); - if (status >= 400 && status < 500 && !isSandboxOrigin(err)) { - const declared = (err as { message?: unknown } | null | undefined)?.message; - if (typeof declared === 'string' && declared.length > 0) return declared; - } - console.warn( - '[Protocol] Withheld a caught error\'s text from a batch row: the producer declared no ' - + 'client-facing refusal (no 4xx status or statusCode, not the VALIDATION_FAILED shape, and not a ' - + 'sandboxed body\'s business refusal), so its ' - + 'sentence must not be quoted back on response data. The row says: ' - + `"${fallback}" — cause (withheld from the response):`, - err, - ); - return fallback; -} - -/** - * [#8441] The client-facing `code` for a failed row on a batch verb's - * `failed[]` — the caught error's own code when the CATALOG declares it, and - * otherwise a catalogued stand-in. - * - * ## The measurement - * - * The sibling of {@link clientFacingFailureText}, one field over, and it needs - * a DIFFERENT rule. #8333 closed the `error` limb at these same two collectors - * and left this one explicitly alone. Measured afterwards — on the fixed branch, - * with the message withhold already in place — `publishPackageDrafts` still - * answered `{ error: 'publish failed', code: 'SQLITE_ERROR' }`: the sentence - * withheld and the driver's own dialect still shipping beside it, on response - * DATA that no HTTP boundary's withhold can reach. `revertCommit` measured the - * same, `{ error: 'revert failed', code: 'SQLITE_ERROR' }`. - * - * ## ⛔ NOT {@link declaresClientRefusal}'s question - * - * That predicate asks whether a producer AUTHORED this sentence for a caller, - * because a message is free text and no catalog bounds it. This field is - * `ApiErrorSchema.code`: a CLOSED union (ADR-0112 D4) whose membership test - * this file already has — {@link carryCatalogedErrorCode}'s, itself verbatim - * {@link toRowApiError}'s. So the question here is **"is this code in the - * catalog?"**, and asking the 4xx question instead would be the wrong test on - * the wrong field twice over: it would admit `SQLITE_ERROR` from any producer - * that happened to declare a 4xx, and it would reject - * `ERR_DATASOURCE_UNAVAILABLE` — a code the ledger registered. - * - * ## Why it SUBSTITUTES rather than drops the limb - * - * `code` is the half a client BRANCHES on. `BATCH_ABORTED` rides the same - * array, and the Studio publish form highlights the offending field from - * `INVALID_METADATA` plus the structured `issues` beside it. Deleting the limb - * would trade a real authoring surface for a disclosure narrower than the one - * #8333 already closed — a worse deal than doing nothing. So a catalogued code - * passes through untouched, and only an uncatalogued one is replaced: by the - * status-derived standard code when the error declared a status, otherwise - * `INTERNAL_ERROR`. That derivation is {@link toRowApiError}'s, reused rather - * than restated, so there is one rule and one place to change it. ⛔ No code is - * minted here — ADR-0114's catalog already names every value this returns. - * - * ## What it deliberately does not touch - * - * An error carrying **no** code still produces **no** limb: `...(code ? …)` is - * the shape both call sites had, and it is preserved. Minting a code where the - * wire never carried one is an ADDITION to the response — a separate contract - * decision, and the same posture {@link carryCatalogedErrorCode} takes toward - * `status`. `issues` is unchanged for the same reason it was out of #8333's - * scope: it is structured authoring feedback, and no driver error carries it. - * - * The other two `failed[]` collectors that build this limb — - * `discardPackageDrafts` and `deletePackage` — need no filter and got none. - * Measured under the same failing driver: their rows carry no `code` at all. - * Both wrap exactly one call, {@link - * ObjectStackProtocolImplementation.deleteMetaItem}, whose two re-wrap exits - * ALREADY run {@link carryCatalogedErrorCode}, so an uncatalogued driver code - * never survives to them. Adding a second filter there would be consumer-side - * tolerance over a producer that does not need it (Prime Directive #12) — - * the same call #8136 recorded at those sites for the message. - */ -export function clientFacingFailureCode(err: unknown): string | undefined { - const code = (err as { code?: unknown } | null | undefined)?.code; - if (!code) return undefined; - if (typeof code === 'string' && ErrorCode.safeParse(code).success) return code; - const status = (err as { status?: unknown } | null | undefined)?.status; - return typeof status === 'number' ? standardErrorCodeForHttpStatus(status) : 'INTERNAL_ERROR'; -} - -/** - * [#8333] The seed-request schema rejection, DECLARED — a 422 refusal with the - * same envelope `saveMetaItem` raises for a failed overlay parse. - * - * ## Why this exists rather than a wider rule at the collector - * - * {@link ObjectStackProtocolImplementation.applySeedBodies} reports failure as - * DATA (`seedApplied.error`), and #8136's rule quotes a caught sentence only - * when the error DECLARED itself a client-facing refusal. Measured on - * `origin/main`, that catch receives two populations: - * - * 1. **driver text** — a `sys_metadata` read failing underneath the loader's - * dependency-graph walk arrives verbatim, and `seedApplied` rides on a - * `200` publish response, so no HTTP boundary can withhold it; - * 2. **the seed bodies' own schema rejection** — `SeedLoaderRequestSchema` - * used to `parse()`, so a malformed seed reached the same catch as a raw - * `ZodError`. That is AUTHORING feedback and it must survive — but it - * declared nothing, so the #8136 rule would have blanked it. - * - * ⛔ The wrong cure is loosening the rule at the collector: that turns a - * positive list into a guess and re-admits population 1. The right one is the - * direction #8136 set — **declare the refusal at its own producer**. So the - * parse becomes a `safeParse` and its rejection is minted here as a real - * ADR-0112 envelope (422 / `INVALID_METADATA` / structured `issues`), which - * then satisfies {@link declaresClientRefusal} on its own merits and is quoted - * back to the author unchanged by a collector that needed no special case. - * - * The author is also strictly better off: the old path stringified a whole - * `ZodError`, so `seedApplied.error` was a multi-line JSON dump of raw zod - * internals. The curated findings {@link zodIssuesToMetadataIssues} produces - * for every other authoring surface ride this error's `issues` — surfaced on - * `seedApplied.issues` by the catches (#10524) — and the message is their - * one-sentence headline. - * - * [#8443] EXPORTED alongside {@link clientFacingFailureText}: the runtime - * package-publish door parses the SAME `SeedLoaderRequestSchema` in its own - * seed-apply fallback and hits the identical two-population catch, so it needs - * the identical declaration — same sentence, same `INVALID_METADATA`/422, same - * curated `issues`. Minting a second 422 over there would give one authoring - * mistake two different envelopes depending on which protocol served the - * publish. - */ -export function seedRequestValidationError(zodIssues: unknown): Error { - const issues = zodIssuesToMetadataIssues(zodIssues); - // [#10524] `message` is a HEADLINE — count plus `path [zod code]` - // locators — never a restatement of the issue prose: the same `issues` - // array rides the error structurally, and the catches that surface this - // refusal thread it onto `seedApplied.issues` beside the headline, so - // the author's curated per-key prose still arrives exactly once. - const err = new Error( - `The published seed bodies failed spec validation: ` - + metadataIssueHeadline(issues), - ); - (err as any).code = 'INVALID_METADATA'; - (err as any).status = 422; - (err as any).issues = issues; - return err; -} - -/** - * [#10524] The one-sentence headline for a refusal whose per-path detail - * rides `issues[]` structurally: total count plus up to three - * `path [zod code]` locators. Restating the issue MESSAGES here is exactly - * the duplication #10524 removed — every console rendering both channels - * showed each finding twice — so the message names WHERE and HOW MANY and - * leaves the prose to the structured channel. The leading count subsumes the - * old `(+N more)` tail. The author-time gate composes its own analogue with - * `[rule]` locators (`runtime-authoring-gate.ts`), deliberately: rule ids - * and zod codes are different vocabularies and folding them into one helper - * would blur which one a reader is looking at. - */ -function metadataIssueHeadline(issues: MetadataIssueEntry[]): string { - const locators = issues.slice(0, 3) - .map((i) => `${i.path || ''}${i.code ? ` [${i.code}]` : ''}`) - .join('; '); - return `${issues.length} issue${issues.length === 1 ? '' : 's'} — ${locators}`; -} - -/** - * [commit d806081dd] The `422 INVALID_METADATA` findings clause, rendered PER FACE. - * - * The refusal is raised in one place ({@link - * ObjectStackProtocolImplementation.saveMetaItem}'s overlay spec check) and - * quoted onto whatever response the caller's catch builds, so one clause is - * read on every face at once — and the faces do not agree on whether the - * structured `issues[]` array reaches the consumer beside it. Where it does, - * restating the prose here is the #10524 duplication exactly: every console - * rendering both channels shows each finding twice. Where it does NOT, this - * clause is the SOLE carrier and trimming it deletes the author's - * prescription from the wire outright. - * - * ## The polarity is the load-bearing decision, not the wording - * - * Silence renders the FULL prose — byte-identical to what every face carried - * before this card. Only a face that positively declares it carries the - * structured channel gets the headline. That direction is deliberate and is - * the opposite of a convenience default: - * - * - the destructive direction (deleting prose) requires an explicit, - * reviewable declaration at a call site, so it can never happen by - * omission; - * - a new write door added later, or one whose author never read this - * comment, keeps the complete sentence. The failure mode of forgetting is - * a redundant sentence on a console, not an author who is told a body is - * invalid and never told which key. - * - * ⛔ Do not invert this to "trim by default, message-only faces opt out". - * Measured on `origin/main` for THIS gate (see - * `protocol.invalid-metadata-422-face-inventory.test.ts`): four of the seven - * callers are message-only, and two of them live in OTHER packages - * (`@objectstack/runtime`'s ADR-0045 visibility flip, `plugin-security`'s - * permission-set projection) where no face can be stated without making the - * field caller-settable. Under the inverted polarity those two lose their - * prescription silently — and `plugin-security`'s remedy sentence - * ("make the record body spec-valid (the error names the offending key)") - * is written on the assumption that this clause names it. - * - * ⚠️ Unlike the 409's {@link destructiveChangeRemedy}, whose faces differ on - * WHAT REMEDY EXISTS, the faces here differ only on WHETHER A SECOND CHANNEL - * CARRIES THE SAME FACTS. Nothing is withheld from anyone: `err.issues` is - * attached identically on every face, and the headline still names how many - * findings there are and where they are. - */ -export function specValidationFindings( - face: MetadataWriteFace | undefined, - issues: MetadataIssueEntry[], -): string { - switch (face) { - case 'meta-envelope': - case 'meta-dispatch': - // The door answers with an ADR-0112 error envelope that carries - // `issues[]` beside the message (`@objectstack/rest`'s - // `sendError` threads a top-level `issues`; `@objectstack/runtime`'s - // dispatcher threads `details.issues`). The prose lives there, - // once. Same headline grammar the seed refusal and the author-time - // gate compose — count plus `path [zod code]` locators. - // - // ⚠️ [#11095] TWO faces, one case, on purpose. The dispatcher split - // off `'meta-envelope'` because the 409 needed a different REMEDY - // there — nothing about the 422 moved, and this door's structured - // channel (`details.issues`) is the very one the comment above - // names. Letting `'meta-dispatch'` fall to the default instead would - // have re-introduced the duplication commit d806081dd removed on one door only, with - // every 409 test green: the polarity below is "declare to trim", so - // a face that stops declaring loses the trim SILENTLY. Pinned in - // `protocol.destructive-409-face-inventory.test.ts`'s [#11095] - // section, which asserts the 422 clause did not move under it. - return metadataIssueHeadline(issues); - default: - // Byte-identical to the clause before commit d806081dd: the first three findings - // as `: `, then a `(+N more)` tail. Read by - // `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s - // `rows[].reason`, `importUnmanagedPackage`'s rolled-back refusal - // (`'package-unmanaged-import'`, whose one door relays no - // `issues[]` — see `MetadataWriteFace`), and the two - // out-of-package log faces — none of which carry `issues[]` to - // their caller. - return issues.slice(0, 3) - .map((i: { path: string; message: string }) => `${i.path || ''}: ${i.message}`) - .join('; ') - + (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''); - } -} - -/** - * A batch row that names no record id for an operation that needs one — a - * caller error, so it carries VALIDATION_FAILED / 400 rather than falling - * through {@link toRowApiError}'s unclassified-throw default (#4793). - */ -export function rowRequiredIdError(operation: 'update' | 'delete'): Error { - const err = new Error(`Record id is required for ${operation}`) as Error & { code?: string; status?: number }; - err.code = 'VALIDATION_FAILED'; - err.status = 400; - return err; -} - -/** - * [#5532] The client-facing sentence for "the `sys_metadata` overlay read - * failed, so I do not know whether this item exists". - * - * Deliberately does NOT interpolate the driver's own message. #5437 records - * why: the REST boundary drops a 5xx's prose unconditionally, and the two - * write-side 500s in this very file (`Failed to persist customization overlay - * to sys_metadata: ${dbError.message}`) are the specimen that made it drop — - * a driver line is nowhere near the length bound, so it arrived intact. The - * driver error still reaches the operator: it rides as `cause` on the thrown - * error, and `handleRouteError` / `logWithheldServerFault` print the whole - * object. - */ -const METADATA_STORE_UNAVAILABLE_MESSAGE = - 'The metadata store could not be read, so whether this item exists is unknown. ' - + 'Retry once the metadata database is reachable.'; - -/** - * [#5532] A `sys_metadata` READ that failed for a reason that is NOT "the table - * has not been provisioned yet" — i.e. the rows may well exist and simply were - * not seen. - * - * 503, not 500: nothing about the REQUEST is wrong, the condition is a - * dependency outage that may clear, and a caller/proxy SHOULD retry. That is - * the same verdict `mapDataError` already gives `ERR_DATASOURCE_UNAVAILABLE`. - * `SERVICE_UNAVAILABLE` is the standard catalog's own code for 503 - * (`HttpStatusErrorCodeMap[503]`, ADR-0112) — a catalogued code rather than an - * invented string, and no new ledger vocabulary for a distinction no consumer - * measures today. - */ -export function metadataStoreUnavailableError(cause: unknown): Error { - const err = new Error(METADATA_STORE_UNAVAILABLE_MESSAGE) as Error & { - code?: string; - status?: number; - cause?: unknown; - }; - err.code = 'SERVICE_UNAVAILABLE'; - err.status = 503; - err.cause = cause; - return err; -} - -/** - * [#12536] The producer-side CLASSIFICATION every `sys_metadata` read failure - * now passes through — the one door, so that "is this a store fault or an - * application refusal?" is answered once instead of per call site. - * - * ## The defect it closes - * - * A metadata app's sandboxed hook on `sys_metadata` may REFUSE a read and mark - * its refusal with `userMessage` — the producer-side opt-in commit 79c46da90 added, where the - * field's PRESENCE is the marking (maintainer ruling, 2026-08-19, objectui#5210 - * option 1). Every such refusal used to be handed straight to {@link - * metadataStoreUnavailableError}, which builds a FRESH error carrying only - * `code` / `status` / `cause`. `declaredUserMessage` reads the TOP level and - * never `cause`, so the mark died at the producer: measured on `origin/main`, - * a marked `beforeFind` refusal and a `connect ECONNREFUSED` came out of - * `getMetaItems` as the same `503 SERVICE_UNAVAILABLE` with the same sentence - * and `declaredUserMessage === undefined` on both. A hook author's localized - * refusal could not reach an end user through this limb at all, and the - * console substituted its #3821 generic string. - * - * ## The ruling this implements - * - * Maintainer, 2026-08-27 (verbatim: 「同意」), option B: a producer-marked - * application refusal must NOT be wrapped as a store-unavailable error in the - * first place. It travels its OWN refusal category with the author's text - * intact; a genuine store fault keeps #8136's deliberately non-quoting 503, - * untouched. Both prior rulings stand — they stop colliding here. - * - * ⛔ The declined shortcut, recorded so nobody re-implements it: forwarding the - * mark ACROSS the 503 (a `cause` fall-through in `declaredUserMessage`, or a - * `userMessage` copied onto the store-unavailable error). That would open a - * quoting exception in the one door #8136 built to quote nothing, and the - * typical failure of that shape is passing the whole cause chain through — - * a silent leak. Nothing from `cause` crosses the 503 door: this function - * either builds the refusal INSTEAD of the 503, or leaves the 503 exactly as - * it was. - */ -export function metadataReadFailureError(cause: unknown): Error { - // {@link declaredUserMessage} is THE read — "never with an inline `typeof` - // probe" is that function's own instruction, and it is why a blank or - // non-string `userMessage` is not a declaration here either. - const marked = declaredUserMessage(cause); - if (marked !== undefined) return markedApplicationRefusalError(cause, marked); - return metadataStoreUnavailableError(cause); -} - -/** - * [#12536] The refusal category a producer-MARKED failure travels in, built so - * the author's text survives verbatim and nothing else from the cause does. - * - * ## What is quoted, and what is not - * - * `message` is the marked text itself. That is the one string the producer - * declared is addressed to the end user (commit 79c46da90), so quoting it discloses - * nothing that was not authored for a caller — and it is the ONLY thing taken - * from the cause. The cause's own `message` is a diagnostic and is NOT read: - * it rides on `cause`, which `handleRouteError` / `logWithheldServerFault` - * print whole for the operator, the same posture {@link - * metadataStoreUnavailableError} takes. - * - * ## Status and code are RESOLVED, never re-spelled - * - * `resolveThrownHttpError` is imported for both, for the reason {@link - * clientFacingRowFailureText} gives: a fourth local spelling of "what did this - * throw declare?" is how two seams start disagreeing about one error. It folds - * `status`, `statusCode` and the validation shape into one status, and gives - * the code rule this file already applies elsewhere — a catalogued `.code` - * passes through, anything else demotes to the status-derived standard code, - * so `ApiErrorSchema`'s closed union (ADR-0112 D4) can never receive a - * driver's dialect. - * - * The fallback status is 400, and it is NOT invented here: it is what the REST - * sandbox door already answers for a hook refusal that named no status of its - * own (`error-response.ts`, commit 8f266f1cd — `declared ?? 400`, pinned by - * `hook-error-format.dogfood.test.ts`). A hook that DID name one keeps it - * (#7867: "an error that NAMES its own HTTP status is asking to be served with - * it"), which is also why this is status-agnostic in the way the `userMessage` - * contract requires — a 400, 403, 409 or 503 refusal may all carry the mark. - */ -function markedApplicationRefusalError(cause: unknown, userMessage: string): Error { - const { status, code } = resolveThrownHttpError(cause, MARKED_REFUSAL_UNDECLARED_STATUS); - const err = new Error(userMessage) as Error & { - code?: string; - status?: number; - userMessage?: string; - cause?: unknown; - }; - err.code = code; - err.status = status; - err.userMessage = userMessage; - err.cause = cause; - return err; -} - -/** - * [#12536] The status a marked refusal takes when its producer named none — - * the REST sandbox door's own `declared ?? 400` (commit 8f266f1cd), reused rather than - * chosen again, so the metadata read door and the hook door classify one - * undeclared hook refusal identically. - */ -const MARKED_REFUSAL_UNDECLARED_STATUS = 400; - -/** - * [#12536] Carry a producer's `userMessage` mark onto an error this file is - * re-wrapping — the exact sibling of {@link carryCatalogedErrorCode}, one - * field over. - * - * A re-wrap that drops the mark destroys the channel commit 79c46da90 added just as completely - * as building a fresh error does, and for the same reason: `declaredUserMessage` - * reads the TOP level, never `cause`. Copying only the marked field quotes - * nothing else — the cause's diagnostic `message` stays governed by whichever - * withhold rule that exit already applies (#8136), unchanged. - */ -function carryDeclaredUserMessage(target: Error, source: unknown): void { - const marked = declaredUserMessage(source); - if (marked !== undefined) (target as Error & { userMessage?: string }).userMessage = marked; -} - -/** - * [#5532] The terminal "this metadata item does not exist" — structured, so it - * stops falling out of `mapDataError`'s catch-all. - * - * A miss is a 404 with the catalog's own not-found code - * (`HttpStatusErrorCodeMap[404] === 'RESOURCE_NOT_FOUND'`, and the spelling - * `GET /meta/:type/:name` already emits from its app-visibility gate). Before - * this, the throw was a bare `Error`: no `status`, no `code`, so it reached the - * REST boundary's terminal branch and was answered — verbatim as a 400 before - * #5489, as a sanitised `500 INTERNAL_ERROR` after it. Both are wrong answers - * for a plain miss, in opposite directions; neither could be told apart from a - * genuine fault by any client. - */ -function metadataItemNotFoundError(type: string, name: string): Error { - const err = new Error(`Metadata item ${type}/${name} not found`) as Error & { - code?: string; - status?: number; - }; - err.code = 'RESOURCE_NOT_FOUND'; - err.status = 404; - return err; -} - -/** What one pass of the `batchData` record loop produced (ADR-0119 D4). */ -export type BatchDataLoopOutcome = { results: BatchDataRowResult[]; succeeded: number; failed: number }; - -/** - * The canonical `QueryAST` surface (`spec/data/query.zod.ts`), enumerated. - * - * Typed as `Record` so `tsc` pins it to the spec in BOTH - * directions: a key added there is a missing-property error here, a key removed - * there is an excess-property error here. That matters because the set below - * decides what is a query parameter and what is a field filter — silently - * drifting from the AST would resurrect exactly the #4134 failure for whatever - * key was added. - * - * The #4286 tombstones (`joins`, `windowFunctions`) still count: `retiredKey()` - * keeps a retired key in `keyof QueryAST`, so both stay listed — and therefore - * deliberately stay RESERVED at this boundary while the tombstone lives. That - * is the right compat posture: a caller still sending one belongs with the - * prescription, not with a silent `where.joins` filter. When a tombstone ages - * out (~two majors) and the key leaves the spec, the excess-property error - * here is the reminder that deleting its line UN-reserves the name — an object - * field genuinely called `joins` would start resolving as an implicit filter, - * which is a behavior change to call out in that changeset. - */ -const QUERY_AST_KEYS: Readonly> = { - object: true, fields: true, where: true, search: true, searchFields: true, - orderBy: true, limit: true, offset: true, top: true, cursor: true, - joins: true, aggregations: true, groupBy: true, having: true, - windowFunctions: true, distinct: true, expand: true, -}; - -/** - * [#4134] Every query-parameter name `findData` consumes itself, consulted - * AFTER the alias normalization in `findData` has run — so the wire spellings - * that get rewritten (`$top`→`top`→`limit`, `select`→`fields`, `sort`→ - * `orderBy`, `filter`/`filters`/`$filter`→`where`, `populate`/`$expand`→ - * `expand`, `skip`→`offset`, …) are already gone by this point and - * deliberately do NOT appear here. Anything still standing is either a name in - * this set or a candidate field filter. - * - * The structural AST keys (`object`, `joins`, `having`, `windowFunctions`) - * matter even though no querystring carries them: `POST /data/:object/query` - * hands its body in as `query`, and that body IS a `Partial`. Without - * them, `client.data.query('task', { object: 'task', limit: 5 })` would have - * its `object` key read as a filter and match zero rows. - * - * A name in this set can never be used as an implicit field filter, so an - * object with a field genuinely called e.g. `count` or `cursor` must filter it - * through the explicit form (`?filter={"count":3}`). That trade-off predates - * #4134 for the original members; it is called out here so the next person to - * add one knows what they are spending. - */ -const RESERVED_LIST_QUERY_PARAMS: ReadonlySet = new Set([ - ...Object.keys(QUERY_AST_KEYS), - // Transport-only extras the normalizer consumes but the AST does not name. - 'count', // ?count / $count — response flag, not a projection - // `searchFields` used to be listed here as such an extra. It is a named - // AST key since #3899 declared it (ADR-0061 P1), so it now arrives through - // the spread above and the type-level pin covers it — the hand-maintained - // copy would have been a second source that could silently fall out of step. - // Server-derived, never caller input (stripped then re-set from `request`). - 'context', -]); - -/** - * [#4134] High-frequency wrong guesses → the parameter that actually works. - * Keys are normalized (lower-cased, `_`/`-` stripped) so `pageSize`, - * `page_size` and `PAGE-SIZE` all land on the same entry. - * - * This is a HINT table, not an alias table: nothing here is accepted as input. - * Adding an entry makes a rejection more helpful; it never makes a request - * succeed, so it does not create the second de-facto contract Prime Directive - * #12 warns about. - */ -export const QUERY_PARAM_NEAR_MISS: Readonly> = { - // page-size dialects (the #4134 repro: `?pageSize=5` → 200 + empty list) - pagesize: 'top', persize: 'top', perpage: 'top', pagelimit: 'top', - rowsperpage: 'top', pagecount: 'top', size: 'top', take: 'top', - first: 'top', max: 'top', maxresults: 'top', maxrecords: 'top', - // page-offset dialects - page: 'skip', pageno: 'skip', pagenum: 'skip', pagenumber: 'skip', - pageindex: 'skip', start: 'skip', startindex: 'skip', startat: 'skip', - // sorting - sortby: 'sort', sortfield: 'sort', sortorder: 'sort', order: 'sort', - ordering: 'sort', - // search - q: 'search', keyword: 'search', keywords: 'search', term: 'search', - searchterm: 'search', querytext: 'search', - // filtering - filterby: 'filter', criteria: 'filter', conditions: 'filter', - // projection / relations - columns: 'select', include: 'expand', includes: 'expand', with: 'expand', -}; - -/** - * The OData spelling of each parameter {@link QUERY_PARAM_NEAR_MISS} points at. - * NOT derivable by prefixing `$` — `sort` is `$orderby`, and suggesting a - * `$sort` that the unsupported-`$` guard rejects would just hand the caller a - * second 400. - */ -export const ODATA_SPELLING: Readonly> = { - top: '$top', skip: '$skip', sort: '$orderby', search: '$search', - filter: '$filter', select: '$select', expand: '$expand', -}; - -// [#16066] The transport alias tables this normalizer folds by are DECLARED, -// and they are declared in the spec — `QUERY_TRANSPORT_ALIAS_SLOTS` and -// `QUERY_TRANSPORT_DOLLAR_ALIASES` (`@objectstack/spec/data`), imported above. -// -// They used to live here as module-private `WIRE_QUERY_ALIAS_SLOTS` / -// `WIRE_DOLLAR_ALIASES`: the spec's own table extended, in this file, with -// spellings no schema named. That extension is what made the `findData` door -// accept a second vocabulary through a slot declaring only the first — two -// dialects, one slot, one of them declared. Folding by the spec's export is -// the single source that closes it; ⛔ do not re-extend a table here. - -/** - * [#7321] The list-query slots whose DECLARED value type admits an array, by - * canonical key. Everything else this normalizer reads — including a leftover - * key lowered into an implicit field filter — is single-valued, and an array on - * it is a repeated wire parameter (see {@link assertQueryParamArity}). - * - * This set is the whole judgement. `IHttpRequest.query` is - * `Record< string, string | string[] >` and the array arm is produced by a real - * first-party adapter (`NodeHttpServer` hands `?x=1&x=2` through as - * `['1','2']`, measured over a socket on #6878), so `Array.isArray` at this - * boundary means one of exactly two things: a repeated querystring parameter, - * or a JSON array in a `POST /data/:object/query` body. This normalizer serves - * BOTH ingresses and cannot tell them apart — which is why the rule keys off the - * declared TYPE rather than off the request. On a slot that never declares an - * array, `Array.isArray` is unambiguous evidence of repetition; on a slot that - * does, it is the ordinary shape and must not be touched. - * - * Per member, why the array is legal (`packages/spec/src/data/query.zod.ts`): - * - `fields` — `z.array(FieldNodeSchema)`; `?$select=a&$select=b` IS the - * projection `['a','b']`. - * - `orderBy` — `z.array(SortNodeSchema)`, and `normalizeSortNodes` has an - * explicit `string[]` arm: repetition COMPOSES into a - * multi-key sort (`?sort=name&sort=-age`), it does not - * conflict. - * - `expand` — the name-array arm lowers to `{name: {object: name}}`. - * - `searchFields` — `z.array(z.string())`; the engine reads the comma-string - * and the array from either slot. - * - `where` — a FILTER AST is an array (`['status','=','open']`), so a - * blanket arity refusal here would reject the AST body form - * outright. Arity for this slot is judged one layer up, at - * the REST querystring ingress (`assertFilterParamSuppliedOnce`, - * #7390) — the only layer that knows an array on this wire - * is a repetition and can be nothing else. A repeated - * `?filter=` never reaches this block on `GET /data/:object`; - * worse, `?filter=status&filter=%3D&filter=open` spells a - * valid AST and succeeds with a filter nobody expressed. - * - `groupBy` — `z.array(GroupByNodeSchema)`. - * - `aggregations` — `z.array(AggregationNodeSchema)`. - * - `joins` / `windowFunctions` — retired ARRAY keys (#4286). The tombstone, - * not an arity refusal, must stay their answer. - * - * Deliberately NOT here, each because the spec declares a scalar: `limit`/`top` - * and `offset` (`z.number()`), `search` (`string | FullTextSearch`), `object` - * (`z.string()`), `having` (a `FilterCondition` OBJECT — the engine has no AST - * arm for it), the `count` response flag, and the retired scalars `cursor` / - * `distinct`. - */ -const ARRAY_VALUED_QUERY_SLOTS: readonly string[] = [ - 'fields', 'orderBy', 'expand', 'searchFields', 'where', - 'groupBy', 'aggregations', 'joins', 'windowFunctions', -]; - -/** - * [#7321] {@link ARRAY_VALUED_QUERY_SLOTS} expanded to every TRANSPORT spelling that - * reaches it, derived from the same two tables the fold uses so a new alias - * cannot silently lose its array arm — the failure mode would be `?$select=a& - * $select=b` starting to 400, i.e. the damage case this card exists to avoid. - */ -const ARRAY_VALUED_LIST_QUERY_PARAMS: ReadonlySet = (() => { - const names = new Set(ARRAY_VALUED_QUERY_SLOTS); - for (const slot of QUERY_TRANSPORT_ALIAS_SLOTS) { - if (!names.has(slot.canonical)) continue; - for (const alias of slot.aliases) names.add(alias); - } - for (const [dollar, bare] of QUERY_TRANSPORT_DOLLAR_ALIASES) { - if (names.has(bare)) names.add(dollar); - } - return names; -})(); - -/** - * [#7321] A parameter this normalizer reads as single-valued, supplied more - * than once. - * - * ## Why a refusal, and why this code - * - * `?$top=1&$top=2` is a well-formed request carrying two irreconcilable - * intents. Picking one is the silent drop itself, and coercing the pair is - * worse: `Number(['1','2'])` is `NaN`, so the window reached the driver as - * `limit: NaN` — driver-dependent behaviour under a 200, never an error. That - * is the same class #6928 / PR #7299 refused one layer over on - * `GET /api/v1/notifications`, and the same rule commit 293476148 / #6877 landed in - * `packages/rest` (`readSingleQueryValue` / `repeatedQueryParamMessage`). - * - * ## The wording below is MODELLED ON theirs — it is not a verbatim copy - * - * A caller who repeats a parameter on two different routes is told the same - * RULE, in two texts that differ in two measured ways (#17813): - * - * 1. The parameter name is quoted differently — single quotes here - * (`The 'top' query parameter …`), double quotes in `packages/rest` - * (`The "top" query parameter …`) — so the two strings diverge at the - * FIRST quoted character, shared prefix included. - * 2. This one appends the `It was NOT applied as a list: …` clause naming - * the `Number(['1','2'])` coercion above. Additive, and absent there. - * - * ⛔ Neither delta is a stray to "repair" in passing: both texts are the - * published output of released endpoints, so aligning the two is a change to - * what two doors answer, not a comment fix. - * - * ## Why the text is copied at all, now that half the reason has gone - * - * #17672 published `repeatedQueryParamMessage` from `@objectstack/rest`'s - * entry, so the sentence that module owns became importable by a sibling - * package for the first time — but not from HERE. `@objectstack/rest` is in - * neither this package's `dependencies` nor its `devDependencies`, and the - * arrow points the other way: `@objectstack/rest` dev-depends on - * `@objectstack/metadata-protocol` (`query-multiplicity.ts`'s header records - * that as why it cannot derive the filter-slot spellings from here at - * runtime). Importing the message would invert a package dependency and - * settle where the sentence lives for THREE consumers — this normalizer, the - * rest handlers, and the dispatcher domain that already calls the message - * function — a layering question deliberately left open. Until it is - * answered, the copy is the only form reachable from this package, ⛔ nothing - * holds the two texts equal, and an edit to either one does NOT move the - * other. - * - * `INVALID_REQUEST` / 400 is what {@link conflictingQueryParamsError} in this - * same normalizer already answers for the IDENTICAL condition reached the other - * way — two SPELLINGS of one slot carrying different values (#4181 → #3795). - * One slot given two values is one defect; it must not carry two codes - * depending on whether the caller repeated `filter` or wrote `filter` and - * `where`. (The rest layer spells its 400 `VALIDATION_ERROR` and the runtime - * layer `VALIDATION_FAILED`; those are each package's house catalog member for - * a 400, registered per package in `error-code-ledger.zod.ts`. The RULE and the - * status are what have to agree across the three, and do.) - * - * ## Why the count and not the values - * - * Two identical values are still two occurrences and are still refused: "at - * most one DISTINCT value" would be a de-duplication rule no caller can - * predict, while "supply it at most once" is checkable client-side without - * knowing anything about our semantics (#6877). - */ -function repeatedQueryParamError(param: string, count: number): Error { - const err: any = new Error( - `The '${param}' query parameter was supplied ${count} times. Supply it at most once — ` - + 'this endpoint will not choose between conflicting values. It was NOT applied as a ' - + 'list: a single-valued parameter given an array coerces to a value nobody asked for ' - + `(Number(['1','2']) is NaN), which the driver then answers under a 200.`, - ); - err.status = 400; - err.code = 'INVALID_REQUEST'; - err.param = param; - return err; -} - -/** - * [#25229] The page window (`limit` / `offset`, spelled `$top` / `top` / - * `$skip` / `skip` on the wire) as the non-negative integer every driver - * needs, or a refusal naming the parameter the caller wrote (#4226). - * - * The bare `Number(...)` this replaces failed three ways, none of them loud: - * - * - `NaN` (`?$top=abc`) and a negative limit (`?$top=-1`) reached the driver, - * which drops a window it cannot read — every row answered under a 200, the - * #7321 class (a malformed window served as if no window was sent); - * - a negative offset (`?$skip=-1`, `{"offset": -1}`) reached knex's - * `QueryBuilder.offset`, which throws — a 500 for a caller's mistake; - * - a blank value (`?$top=`) is `Number('') === 0`, an empty page nobody - * asked for. - * - * A blank value is refused, not read as absent. The blank `?filter=` is - * absent because an empty filter and no filter mean the same thing; an empty - * window does not — absent means "every row", so reading `?$top=` that way is - * the silent widening above under another name, and reading it as `0` is the - * guess this replaces. `0` itself stays valid: `$top=0` asks for the total - * alone. - * - * Only the two arms a door can deliver are read: a number (a JSON body) and a - * string (a querystring). Anything else — `true`, which `Number` would quietly - * make `1` — is refused like any other value that is not a count. - * - * [#25269] The accept set is the spec's `QueryPageWindowParamSchema`, parsed - * here rather than restated: the same declaration is what `FindDataRequestSchema` - * and `HttpFindQueryParamsSchema` publish for these parameters, so a client - * generated from them is told exactly what this check lets through. - */ -function pageWindowValue(value: unknown, param: string): number { - const window = QueryPageWindowParamSchema.safeParse(value); - if (window.success) return window.data; - // `String` for a number: `JSON.stringify(Infinity)` is `null`. - const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value); - const err: any = new Error( - `Query parameter '${param}' must be a non-negative integer; got ` - + `${shown.length > 64 ? `${shown.slice(0, 64)}…` : shown}. The query was not run: a window ` - + 'the driver cannot read is either dropped, answering every row, or rejected by the database.', - ); - err.status = 400; - err.code = 'INVALID_REQUEST'; - err.param = param; - throw err; -} - -/** - * A refused query value as the refusal quotes it: JSON, so a blank or - * whitespace-only value is visible, and bounded, so a long one cannot bury - * the sentence around it. [#27484] Exported for the search-fields gate - * (`protocol-query-validation.ts`), which refuses an empty override in the - * projection's words. - */ -export function quotedQueryValue(value: unknown): string { - // `String` for a number: `JSON.stringify(Infinity)` is `null`. - const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value); - return shown.length > 64 ? `${shown.slice(0, 64)}…` : shown; -} - -/** - * [#26644] A projection (`fields`, spelled `$select` / `select` on the wire) as - * the list of field names the engine reads, or a refusal naming the parameter - * the caller wrote. - * - * A comma string is split and trimmed as it always was; what is refused is a - * value that names NO field — a blank or comma-only string, or an empty array. - * It used to become `[]`, which `assertProjectionFieldsExist` has nothing to - * check in and every driver serves as no projection: every column, under a - * 200. Absent means "every column"; an empty projection asks for none, so - * reading it as absent is the widening {@link pageWindowValue} refuses on the - * row axis (a blank `$top`), here on the column axis. - * - * One rule for both routes that read a projection: the list's `findData` and - * the by-id `getData`. - */ -function projectionFieldsValue(value: string | readonly unknown[], param: string): unknown[] { - const fields = typeof value === 'string' - ? value.split(',').map((s) => s.trim()).filter(Boolean) - : [...value]; - if (fields.length > 0) return fields; - const err: any = new Error( - `Query parameter '${param}' names no field; got ${quotedQueryValue(value)}. The query was not run: ` - + 'an empty projection was read as no projection, which answers every column. Name at least one ' - + 'field, or leave the parameter out to read every column.', - ); - err.status = 400; - err.code = 'VALIDATION_ERROR'; - err.param = param; - throw err; -} - -/** - * [#26644] The response's total-count flag (`count`, spelled `$count` on the - * wire) as the boolean `findData` reads its opt-out from, or a refusal naming - * the parameter the caller wrote. - * - * The accept set is the spec's `QueryTransportParamsSchema.count`, parsed here - * rather than restated: the boolean, or the two strings a querystring carries - * it as (`null` is absent). Any other value used to stay as it was, so the - * `options.count === false` opt-out read it as "count anyway" — `?$count=maybe` - * reported a `total` exactly like `?$count=true`. - */ -function countFlagValue(value: unknown, param: string): boolean | null | undefined { - const flag = QueryTransportParamsSchema.shape.count.safeParse(value); - if (flag.success) return typeof flag.data === 'string' ? flag.data === 'true' : flag.data; - const err: any = new Error( - `Query parameter '${param}' must be true or false; got ${quotedQueryValue(value)}. The query was not ` - + 'run: any other value was read as absent, which counts and reports a total.', - ); - err.status = 400; - err.code = 'VALIDATION_ERROR'; - err.param = param; - throw err; -} - -/** - * [#7321] Refuse a repeated occurrence of any list-query parameter this - * normalizer reads as single-valued, and normalise the benign one-element array - * away so nothing below has to know the union existed. - * - * Runs at the TOP of `findData`, ahead of the `$`-alias pass and the #3795 slot - * fold, for two reasons: - * - * 1. The message then quotes the parameter the caller actually wrote — the - * #4226 discipline. After the fold, `?$top=1&$top=2` would be reported as - * `'limit'`, a name absent from the request. - * 2. The fold compares slot spellings by `JSON.stringify`, so an unchecked - * `?top=1&top=2&limit=1` reaches it as `['1','2']` vs `'1'` and is refused - * as a "conflicting query parameters" problem — a true refusal with a false - * diagnosis. Checking arity first means the caller is told what is actually - * wrong. - * - * Length 0 is DELETED rather than set to `undefined` (which is what the rest - * layer's `refuseRepeatedQueryParams` does with it): the leftover-key bucket - * below reads `Object.keys(options)`, so a key left behind carrying `undefined` - * would be lowered into an implicit `{field: undefined}` predicate. "Not - * supplied" has to mean absent here, not present-and-empty. - */ -function assertQueryParamArity(options: Record): void { - for (const name of Object.keys(options)) { - const value = options[name]; - if (!Array.isArray(value)) continue; - if (ARRAY_VALUED_LIST_QUERY_PARAMS.has(name)) continue; - if (value.length > 1) throw repeatedQueryParamError(name, value.length); - // length 1 → one occurrence an adapter encoded as an array; length 0 → - // no occurrence at all. - if (value.length === 0) delete options[name]; - else options[name] = value[0]; - } -} - -/** - * [#4181 → #3795] Spellings of ONE slot carrying DIFFERENT values. Two values - * for one slot cannot be reconciled — merging them would invent an intent the - * caller never expressed, and picking one is the silent drop itself — so an - * ambiguous request is refused. Redundant identical spellings pass. #4181 - * established this on the filter slot; the fold now applies it to all five. - * - * `spellingFor` maps each folded name back to the wire spelling the caller - * actually wrote (`$orderby`, not `orderBy`) — the #4226 discipline. - */ -function conflictingQueryParamsError( - conflict: QueryAliasConflict, - spellingFor: (name: string) => string, -): Error { - const names = conflict.spellings.map((s) => `'${spellingFor(s)}'`).join(', '); - const err: any = new Error( - `Conflicting query parameters: ${names} are spellings of the same parameter ` - + `(canonical '${conflict.canonical}') and were given different values. Send exactly one.`, - ); - err.status = 400; - err.code = 'INVALID_REQUEST'; - return err; -} - -/** - * [#4181] A filter the normalizer cannot turn into a usable `FilterCondition` - * by any route other than the array shapes {@link malformedFilterArrayError} - * already diagnoses: unparseable JSON, or JSON that parses to something no - * driver can read (a number, a bare string, `null`). - * - * Carries `INVALID_FILTER` — the standard-catalog code (`errors.zod.ts`, - * "Invalid filter expression") that #4121 introduced on this same code path for - * the array case. One condition, one wire code, however the caller reached it: - * a `$filter` array with a bad operator and a `?filter=` that is not JSON are - * the same answer to the same question ("this filter cannot run"). - * - * The message states the filter was NOT APPLIED, because that is the part a - * caller cannot infer: the pre-#4181 behavior was an ordinary-looking 200 over - * the unfiltered set. - */ -function unusableFilterError(param: string, detail: string): Error { - const err: any = new Error( - `Query parameter '${param}' ${detail}. It was not applied, and an unapplied ` - + 'filter would have returned the unfiltered result set.', - ); - err.status = 400; - err.code = 'INVALID_FILTER'; - err.param = param; - return err; -} - -/** - * [#4226] A sort the normalizer cannot turn into a usable `SortNode[]`, or one - * that names a field the object does not have — or, since #4256, a dotted path - * (`account.company_name`) that would have to cross into a related record no - * driver joins for. - * - * Carries `INVALID_SORT` — the standard-catalog code (`errors.zod.ts`, - * "Invalid sort specification") that had sat in the catalog with no emitter - * since it was written. One condition, one wire code, however the caller - * reached it. - * - * The message spells out what an unapplied sort costs, because that is the part - * a caller cannot infer from the response: `sort` + `top` is how you ask for - * "the latest N", and a dropped sort turns that into an ARBITRARY N with a - * perfectly ordinary-looking 200 over it. The rows are all there and all real — - * which is exactly why nobody notices. - */ -export function invalidSortError( - param: string, - detail: string, - opts?: { hint?: string, extra?: Record }, -): Error { - const err: any = new Error( - `Query parameter '${param}' ${detail}. It was not applied, and an unapplied ` - + "sort returns the rows in an arbitrary order — which 'top'/'limit' then " - + 'slices into an arbitrary page.' - + (opts?.hint ?? ''), - ); - err.status = 400; - err.code = 'INVALID_SORT'; - err.param = param; - Object.assign(err, opts?.extra ?? {}); - return err; -} - -/** - * [#4721] A sort node that spells its direction `direction` instead of `order`. - * - * This is the one unknown key on this axis that has a KNOWN right answer, so it - * gets a rejection that carries the translation rather than a generic refusal. - * `direction` is not a typo — it is the live vocabulary of a neighbouring - * contract (better-auth's adapter `sortBy`), which - * `plugin-auth/objectql-adapter.ts` already translates to `order` by hand. - * A necessary translation nothing enforced is exactly ADR-0049's shape. - * - * Measured on `main` before this rejection existed, on the schema side of the - * same door: - * - * ``` - * SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' }) - * → { field: 'updated_at', order: 'asc' } - * ``` - * - * So the failure was not "unsorted" — it was sorted the OTHER WAY, and with a - * `limit` that means a different set of rows came back under a 200. Worse than - * the family `invalidSortError` was built for (#3948, #4226): those return the - * right rows in an arbitrary order, this one returns the wrong rows. - * - * `INVALID_SORT` rather than a new code: one condition — "this sort was not - * applied as written" — keeps one wire code however the caller reached it. - */ -function invalidSortDirectionKeyError(param: string, field: string): Error { - return invalidSortError( - param, - `spells the sort direction for '${field}' as \`direction\`, which is not a key on this ` - + 'axis — the QueryAST sort node is `{ field, order }`', - { - hint: - ` Write \`{ field: '${field}', order: 'desc' }\`. \`direction\` is` - + " the better-auth adapter's `sortBy` vocabulary, a genuinely different contract; on this" - + ' axis it was silently dropped and `order` fell back to `asc`, so a descending' - + ' request came back ascending — and with `limit`, a different set of rows.', - extra: { field, key: 'direction' }, - }, - ); -} - -/** - * [#4226] Every wire spelling of `sort`/`orderBy`, folded to the one shape the - * QueryAST declares (`SortNodeSchema[]`) — or a rejection. - * - * Four spellings arrive here today and only two of them ever reached a driver: - * - * | spelling | example | before | - * |:---|:---|:---| - * | string | `?sort=-created_at` | worked | - * | `SortNode[]` | `[{field,order}]` | worked | - * | `string[]` | `['-created_at']` | **dropped** — the client's own declared type (`orderBy?: string \| string[] \| SortNode[]`) | - * | `Record` | `{created_at:'desc'}` | **dropped** — what `GET /data/:object/export`, `GET /data/import/jobs` and objectui's calendar all emit | - * - * The two dropped ones never failed: `SqlDriver` guards its ORDER BY clause - * with `Array.isArray(query.orderBy)`, so a shape it could not read produced no - * clause at all. `GET /data/import/jobs` has been asking for `created_at desc` - * and serving insertion order ever since it was written; #4181 taught the - * export route to REJECT an unparseable `orderby`, but the parsed result then - * fell into this same hole one layer down. - * - * Normalizing here — in the one shared normalizer every ingress funnels through - * — is what gives `GET /data/:object`, `POST /data/:object/query`, the export - * route and the runtime dispatcher a single answer instead of four. Anything - * that still cannot be read as a sort is a 400 rather than a silent no-op: per - * #3948, an unapplied sort must not look like an applied one. - * - * [#4721] One key gets named treatment on top of that: a node written - * `{ field, direction }` is refused with {@link invalidSortDirectionKeyError} - * rather than read as "sort by `field`, direction unspecified". It is the wire - * half of a door whose schema half is `SortNodeSchema`'s - * `aliases: { direction: 'order' }` — both closed in one change, because - * guarding one door only is the asymmetry #1535 shipped and #4522 came back for. - */ -function normalizeSortNodes(value: unknown, param: string): Array<{ field: string, order: 'asc' | 'desc' }> { - const direction = (raw: unknown, subject: string): 'asc' | 'desc' => { - if (raw === undefined || raw === null || raw === '') return 'asc'; - const dir = String(raw).trim().toLowerCase(); - if (dir === 'asc' || dir === 'desc') return dir; - throw invalidSortError(param, `gives ${subject} the direction '${raw}', which is neither 'asc' nor 'desc'`); - }; - // `-field` / `field` / `field desc` — the querystring shorthand, also used - // for each element of the `string[]` form. - const fromShorthand = (raw: string): { field: string, order: 'asc' | 'desc' } | undefined => { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - if (trimmed.startsWith('-')) { - const field = trimmed.slice(1).trim(); - return field ? { field, order: 'desc' } : undefined; - } - const [field, order] = trimmed.split(/\s+/); - return field ? { field, order: direction(order, `'${field}'`) } : undefined; - }; - const fromElement = (el: unknown, index: number): { field: string, order: 'asc' | 'desc' } | undefined => { - if (typeof el === 'string') return fromShorthand(el); - if (el && typeof el === 'object' && !Array.isArray(el)) { - const node = el as { field?: unknown, order?: unknown }; - if (typeof node.field === 'string' && node.field.trim()) { - // [#4721] `{ field, direction }` — the wire half of the door - // `SortNodeSchema`'s `aliases: { direction: 'order' }` closes on - // the schema side. Checked HERE and not left to the schema - // because an external caller's `orderBy` never reaches it: this - // normalizer runs at the ingress, ahead of any QueryAST parse. - // - // Rejecting only on a well-formed sort NODE is deliberate. In - // the sibling `{field: direction}` map form a key named - // `direction` is an ordinary column name ("sort by the - // `direction` column"), and that form does not reach this - // branch — it has no string `field`. - if ('direction' in node) throw invalidSortDirectionKeyError(param, node.field.trim()); - return { field: node.field.trim(), order: direction(node.order, `'${node.field}'`) }; - } - } - throw invalidSortError( - param, - `has an entry at position ${index} that names no field (received ${JSON.stringify(el) ?? typeof el})`, - ); - }; - - if (value === undefined || value === null || value === '') return []; - if (typeof value === 'string') { - return value.split(',').map(fromShorthand).filter((s): s is { field: string, order: 'asc' | 'desc' } => !!s); - } - if (Array.isArray(value)) { - return value.map(fromElement).filter((s): s is { field: string, order: 'asc' | 'desc' } => !!s); - } - if (typeof value === 'object') { - const entries = Object.entries(value as Record); - if (entries.length === 0) return []; - // A single `SortNode` sent unwrapped (`{field:'x',order:'desc'}`) is the - // one-element array it obviously means; anything else is read as the - // `{field: direction}` map, whose VALUES must be directions — that check - // is what stops a stray object being silently accepted as "no sort". - if (typeof (value as any).field === 'string') return [fromElement(value, 0)!]; - return entries.map(([field, dir]) => ({ field, order: direction(dir, `'${field}'`) })); - } - throw invalidSortError(param, `is a ${typeof value}, which names no field to sort by`); -} - -/** Fold a parameter name to its near-miss lookup key. */ -export function nearMissKey(name: string): string { - return name.toLowerCase().replace(/[_-]/g, ''); -} - -/** Levenshtein distance, bailed out early once it exceeds `max`. */ -function editDistance(a: string, b: string, max: number): number { - if (Math.abs(a.length - b.length) > max) return max + 1; - let prev = Array.from({ length: b.length + 1 }, (_, i) => i); - for (let i = 1; i <= a.length; i++) { - const row = [i]; - let best = i; - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost); - if (row[j] < best) best = row[j]; - } - if (best > max) return max + 1; - prev = row; - } - return prev[b.length]; -} - -/** - * [#4226] The field-typo half of {@link suggestQueryParam}, on its own. - * - * The `sort` / `select` / `expand` axes name a field DIRECTLY, so the - * parameter-dialect half above is not merely useless there but actively wrong: - * it would answer `?sort=order` with "did you mean the 'sort' query parameter", - * which is the parameter the caller already used. - */ -export function suggestFieldName(name: string, knownFields: readonly string[]): string { - const folded = nearMissKey(name); - // Only worth guessing for names long enough that a small distance is - // meaningful — at 3 chars everything is within 2 edits of everything. - if (folded.length >= 4) { - const max = folded.length <= 5 ? 1 : 2; - let best: string | undefined; - let bestDistance = max + 1; - for (const field of knownFields) { - const d = editDistance(folded, nearMissKey(field), max); - if (d < bestDistance) { bestDistance = d; best = field; } - } - if (best !== undefined && bestDistance <= max) { - return ` Did you mean the field '${best}'?`; - } - } - return ''; -} - -/** - * [#7534] The logical combinators a `FilterCondition` may carry. These hold - * NESTED CONDITIONS rather than naming a field, so {@link collectFilterFieldKeys} - * descends through them instead of judging them. - * - * Exactly the three the contract declares (`FilterConditionSchema`, - * `@objectstack/spec`) — `$and` / `$or` / `$not`. `$nor` is deliberately absent: - * it is a driver-INTERNAL lowering (`driver-memory` rewrites an input `$not` - * into a one-operand `$nor`, MongoDB's document-level negation) and is REFUSED - * as input vocabulary by that same driver, so a `$nor` arriving on the wire is - * not a combinator this layer should silently descend into. - */ -export const FILTER_LOGICAL_KEYS: ReadonlySet = new Set(['$and', '$or', '$not']); - -/** - * [#21544] The stored-metadata family's ONE filter-field collector: every column - * a read query's filters READ, judged the way the family's evaluate refusals - * ({@link storedMetadataBodyPredicateRefusal}, - * {@link storedMetadataHashEvaluateRefusal}) need it. The generic data door - * calls it, and so does the in-process reader-context seam in - * `@objectstack/runtime` (`stored-metadata-reader-seam.ts`), on the same query, - * so the two answer every filter identically. `[]` for an object outside the - * family ({@link isStoredMetadataBodyObject}) or a query that is not a record. - * - * The filter positions it reads are a read query's three: `where`, `filter` - * (the engine option alias a direct engine call may still carry) and each - * `aggregations[i].filter`. ⛔ Not `having`: its keys and references name the - * AGGREGATED row's columns — group keys and aggregation aliases — and the - * engine refuses any other name before a row exists, so a family column can - * never be read there, while an alias that happens to be spelled like one - * (`{ function: 'count', alias: 'metadata' }`) is a legitimate count. - * - * What counts as a read, and why it is more than the ingress gate's - * {@link collectFilterFieldKeys} collects. The first two rules are measured - * reaches at the generic data door before this collector existed (a family - * column evaluated, unrefused); the rest are the stricter collector's rules, - * adopted whole so the door and the seam agree — where measured, the door - * already refused those shapes elsewhere (its dotted-path rule, the engine's - * filter doors), and now the family's own refusal answers them first: - * - * - **A cross-field comparand is a read.** `{ name: { $eq: { $field: 'metadata' } } }` - * compares each row's `name` with its stored body, and the SQL drivers - * evaluate it: row presence discloses the referenced column exactly as a - * filter on it would. Every node under a field key that carries a string - * `$field` — an operator bag, a list member, an `addDays` offset — is a read - * of that column: the reference shape `driver-sql` resolves (the spec's - * `FieldReferenceSchema`), taken without its other refinements, so a - * malformed reference is refused here rather than read. - * - **Depth never hides a read.** The walk is iterative and visits every node - * once per reading, so it terminates on a self-referential live object - * without a depth backstop: the ingress collector's backstop stops at 32 - * levels, and a body predicate nested below it was evaluated unrefused. - * - **A dotted name reads its head.** `metadata.x` reads `metadata`, as a - * key and as a reference. - * - **A `$` key is never a column, and what sits beneath one is read as a - * condition** — the three declared combinators and any other alike, so an - * unrecognised combinator cannot hide a family column (the engine refuses - * one anyway; the refusal just arrives first). - * - **A field key's value is read for references only.** The keys of a - * nested-relation condition (`{ owner: { region: 'NA' } }`) are another - * object's columns; a `$field` beneath one is still collected, so the - * ambiguous spelling is refused rather than resolved. - * - **A `FilterArray` is lowered first** (`parseFilterAST`), the sugar a direct - * engine call still honours on `where`, so either authoring form reads the - * same. - * - * Each column is returned once. - */ -export function collectStoredMetadataFilterFields(object: string, query: unknown): string[] { - if (!isStoredMetadataBodyObject(object)) return []; - if (query === null || typeof query !== 'object' || Array.isArray(query)) return []; - const bag = query as Record; - const filters: unknown[] = [bag.where, bag.filter]; - if (Array.isArray(bag.aggregations)) { - for (const aggregation of bag.aggregations) { - if (aggregation !== null && typeof aggregation === 'object') { - filters.push((aggregation as { filter?: unknown }).filter); - } - } - } - const out = new Set(); - for (const filter of filters) collectFilterReads(isFilterAST(filter) ? parseFilterAST(filter) : filter, out); - return [...out]; -} - -/** The head segment of a column name: `metadata.x` reads `metadata`. */ -function headSegment(name: string): string { - return name.split('.')[0] as string; -} - -/** - * The walk {@link collectStoredMetadataFilterFields} runs over one filter - * condition. A node is visited as a CONDITION (its non-`$` keys are columns) - * or as a COMPARAND (the value under a column key, where only a `$field` - * reference is a read), at most once per reading — two readings, because a - * live object reached once as a comparand and once as a condition must be read - * both ways. - */ -export function collectFilterReads(root: unknown, out: Set): void { - const seenAsCondition = new WeakSet(); - const seenAsComparand = new WeakSet(); - const pending: Array<{ node: unknown; condition: boolean }> = [{ node: root, condition: true }]; - while (pending.length > 0) { - const { node, condition } = pending.pop() as { node: unknown; condition: boolean }; - if (node === null || typeof node !== 'object') continue; - const seen = condition ? seenAsCondition : seenAsComparand; - if (seen.has(node)) continue; - seen.add(node); - if (Array.isArray(node)) { - for (const member of node) pending.push({ node: member, condition }); - continue; - } - const record = node as Record; - if (!condition && typeof record.$field === 'string') out.add(headSegment(record.$field)); - for (const [key, value] of Object.entries(record)) { - if (condition && !key.startsWith('$')) { - out.add(headSegment(key)); - pending.push({ node: value, condition: false }); - } else { - pending.push({ node: value, condition }); - } - } - } -} - -/** - * The slice of an object definition {@link narrowStoredMetadataSearch} reads: - * the field map, the declared `searchableFields`, and the display field the - * search resolution falls back on. - */ -export interface StoredMetadataSearchSchema { - fields?: unknown; - searchableFields?: unknown; - nameField?: unknown; - displayNameField?: unknown; -} - -/** - * [#21207, #21544] A `search` on a stored-metadata table never scans its body or - * content-hash columns — the ONE narrowing the generic data door and the - * in-process reader-context seam (`@objectstack/runtime`) both call, so a - * search answers the same through either. - * - * A search is a substring filter the engine evaluates over every column it - * scans, and with no `searchableFields` declared it scans every text-like - * column — the stored body and both stored hashes among them. Over those it is - * the verifier the filter refusals close: a guessed hash, or a guessed prefix - * of withheld credential material, returns the row exactly when it is right. - * The authority on which columns a search may never scan is the family's - * search predicate ({@link storedMetadataSearchRefusal}), asked per field: - * - * - an EXPLICIT field list — `searchFields`, else the object-form - * `search.fields`, the engine's own precedence and both its shapes (a comma - * string or an array) — naming one is refused, `INVALID_FIELD` / 400, the - * evaluate refusals' envelope, under the caller's wire spelling of the slot - * (`wireSpelling`, the door's; the bare canonical name when absent); - * - [#28120] an explicit list that names NO field — an empty array, a blank or - * comma-only string, an array with no string entry — is not "names none": it - * is passed through untouched, for the engine's own refusal to answer - * (`400 VALIDATION_ERROR` for an empty one, #27732; `400 INVALID_FIELD` for - * one naming no searchable field). Narrowing it here wrote a non-empty set - * back over the caller's list, so an override the door and the engine both - * refuse was served. The door never reaches this with one: its ingress gate - * refuses it first; - * - a search that names none is NARROWED: the object's resolved searchable set - * ({@link resolveSearchFieldResolution}) minus those columns is returned, for - * the caller to run as `searchFields` — the engine intersects an override - * with that set and never widens it; - * - a set that narrows to nothing is refused rather than returned empty: an - * empty override is refused by the engine as one naming no field, a - * caller's mistake this caller did not make, so the family's own refusal - * names the cause instead. - * - * Returns `undefined` when the query runs as it is — an object outside the - * family, no search, an explicit list that passes or names no field, or no - * readable field map (the engine has none to expand a search over either; the - * same rule the door's field gates apply: a non-object, array or empty - * `fields`). Throws the refusal. - */ -export function narrowStoredMetadataSearch( - object: string, - query: Readonly>, - schema: StoredMetadataSearchSchema | null | undefined, - wireSpelling: Readonly<{ search?: string; searchFields?: string }> = {}, -): string[] | undefined { - if (!isStoredMetadataBodyObject(object)) return undefined; - const search = query.search; - const objectForm = search !== null && typeof search === 'object'; - const objectFormFields = objectForm ? (search as Record).fields : undefined; - const [explicit, param] = query.searchFields != null - ? [query.searchFields, wireSpelling.searchFields ?? 'searchFields'] - : objectFormFields != null - ? [objectFormFields, wireSpelling.search ?? 'search'] - : [undefined, '']; - const names: string[] = typeof explicit === 'string' - ? explicit.split(',').map((s) => s.trim()).filter(Boolean) - : Array.isArray(explicit) ? explicit.filter((f: unknown): f is string => typeof f === 'string') : []; - if (names.length > 0) { - const refusal = storedMetadataSearchRefusal(object, names, param); - if (refusal) throw refusal; - return undefined; - } - // [#28120] A list naming no field is the engine's to refuse, never narrowed. - if (typeof explicit === 'string' || Array.isArray(explicit)) return undefined; - if (search == null) return undefined; - const fields = schema?.fields; - if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) { - return undefined; - } - const { allowed } = resolveSearchFieldResolution({ - fields: fields as Record, - searchableFields: schema?.searchableFields as string[] | undefined, - displayField: (schema?.nameField ?? schema?.displayNameField) as string | undefined, - }); - const searchParam = wireSpelling.search ?? 'search'; - const narrowed = allowed.filter((field) => !storedMetadataSearchRefusal(object, [field], searchParam)); - if (narrowed.length === 0) { - const refusal = storedMetadataSearchRefusal(object, allowed, searchParam); - if (refusal) throw refusal; - return undefined; - } - return narrowed; -} - -/** - * Service Configuration for Discovery - * Maps service names to their routes and plugin providers. - * - * A missing `route` means the service has NO HTTP surface — discovery must - * not advertise a route for it (ADR-0076 D12, #2462: an advertised route - * with no mounted handler 404s and misleads consumers). Such entries carry - * `noHttpSurface` instead, stating how to report an occupant that does not - * self-describe: `realtime`'s advertised capability IS the missing HTTP/WS - * surface, so an in-process bus is `degraded`; `cache`/`queue`/`job` are - * kernel-internal contracts fully served in-process (#4318), so an unmarked - * real implementation stays `available`. Either way `handlerReady` is - * reported `false` — for a route-less slot it is not a proxy for anything, - * it is the fact itself. - */ -/** - * [#4093 follow-up] `plugin` is no longer written here. It named the package a - * consumer should install, and ten of the fifteen names did not exist — - * `plugin-redis` / `plugin-bullmq` / `job-scheduler` / `plugin-notifications` / - * `plugin-storage` / `plugin-automation` / `ui-plugin`, plus `plugin-ai`, - * `plugin-search` and `plugin-workflow` for slots nothing implements at all. - * The value is surfaced as discovery's `provider` and as its remedy line, so a - * wrong name is a dead end handed to whoever is trying to fix their stack. - * - * It now comes from `CORE_SERVICE_PROVIDER` in `@objectstack/spec/system`, the - * one table both discovery builders read, verified against what actually - * registers each slot and guarded by `scripts/check-service-providers.mjs`. - * Only the ROUTE stays local — that is this builder's own knowledge. - */ -const SERVICE_CONFIG: Record = { - // Plugin-provided like every other optional service since the degraded - // ObjectQL fallback was retired (#3891): advertised iff the real engine - // is registered — never hardcoded 'available' (the pre-#2462 lie the - // fallback existed to paper over). - analytics: { route: '/api/v1/analytics' }, - auth: { route: '/api/v1/auth' }, - automation: { route: '/api/v1/automation' }, - // Kernel-internal slots (#4318): their providers (service-cache/-queue/ - // -job) mount no HTTP routes — these are in-process contracts, not HTTP - // capabilities, so there is no route to advertise and never will be. The - // /api/v1/cache|queue|jobs paths this table used to declare existed - // nowhere else in the repository; every default boot advertised them next - // to the fallbacks' own `handlerReady: false` — a single ServiceInfo - // contradicting itself. - cache: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('cache') } }, - queue: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('queue') } }, - job: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('job') } }, - ui: { route: '/api/v1/ui' }, - // `workflow: { route: '/api/v1/workflow' }` retired with the slot (#4451, - // v17): nothing ever registered or resolved it (ADR-0115 Evidence 5) and - // no host ever mounted the path. State machines are `state_machine` - // validation rules; approvals are flow nodes (ADR-0019). - // service-realtime is an in-process pub/sub bus; nothing mounts - // /api/v1/realtime, so no route is advertised (D12, #2462). Unlike the - // kernel-internal slots above, the capability this slot advertises is - // realtime push to clients — without a surface that IS reduced, so an - // unmarked bus reports degraded. Message matches the dispatcher builder. - // - // [#14646] And it is the one CHANNEL SLOT (`CHANNEL_SURFACE_SLOTS`, - // `@objectstack/spec/api` — the one place that membership is declared, read - // by BOTH discovery producers, which is why it is not a field of this - // table). The row used to yield `enabled: true` ("the slot is filled") - // beside the message below ("no surface is mounted") — both true at once, - // which is how `enabled` came to carry two meanings and a console client - // came to subscribe to nothing. `enabled` is now `isSubscribableChannel` - // and the route is the one the occupant names, so the stock in-process bus - // advertises no channel while a host that really mounts one still does - // (maintainer ruling A, 2026-09-04 — realtime stays out of open core). - // `noHttpSurface` stays: it supplies the status and the sentence for the - // unmounted case, which is every host the open framework ships. - realtime: { noHttpSurface: { statusWhenUnmarked: 'degraded', message: 'In-process event bus only — no HTTP/WS realtime surface is mounted' } }, - notification: { route: '/api/v1/notifications' }, - ai: { route: '/api/v1/ai' }, - i18n: { route: '/api/v1/i18n' }, - // `graphql: { route: '/graphql' }` was here until #4451. It was never a - // `CoreServiceName`, so nothing could ever occupy the slot and the entry - // was unreachable — but it declared a path the dispatcher had already - // removed as out of the product plan (`http-dispatcher.ts`: "/graphql - // removed — GraphQL is not in the product plan", #2462 follow-on). A - // route nobody serves, for a slot that does not exist, in the table SDKs - // and AI clients read. - // Canonical slot since #9683 (was `file-storage`; that spelling stays a - // deprecated v17 registry alias, mirrored verbatim into the services map - // after the loop below — never a SERVICE_CONFIG row of its own). - storage: { route: '/api/v1/storage' }, - search: { route: '/api/v1/search' }, -}; - -/** - * Phase 3a-references: how a reference is FOUND in a stored document, given the - * derived site index in `./reference-sites.ts`. - * - * The registry that used to sit here was a hand-curated table of `fromType` + - * fully-qualified dotted paths. It rotted — 34 of its 40 paths named properties - * the source type's schema does not declare, leaving five of its seven target - * keys answering `{ references: [] }` unconditionally (#9190; the measurements - * are in `reference-sites.ts`'s header, which is the one place they live). - * - * ⛔ Do not reintroduce a path table. Paths were the wrong unit: `AppSchema`'s - * navigation is recursive, so an exhaustive path list is unbounded, and it goes - * stale the moment a wrapper key moves. The derived index records the PROPERTY - * that carries a name; this walk finds it wherever the document actually puts - * it, and reports the path it was found at. - */ - -/** - * Every name a reference-bearing property value may be carrying, tolerant of - * the four shapes a name can arrive in. - * - * Deliberately shape-TOLERANT at read time while the derivation is shape-STRICT - * at build time: the derivation decides whether a property means "a reference" - * (and rejects `enum`-constrained lookalikes such as a chart axis `position`), - * while this function only has to read a value whose meaning is already - * settled. Record KEYS are included because `z.record(name, …)` is how the - * platform spells a name-keyed collection — `PermissionSetSchema.objects` is - * exactly that, and the old table's `objects[].name` could not express it. - */ -function referencedNamesAt(value: unknown): Array<{ name: string; suffix: string }> { - if (typeof value === 'string') { - return value.length > 0 ? [{ name: value, suffix: '' }] : []; - } - if (Array.isArray(value)) { - const out: Array<{ name: string; suffix: string }> = []; - for (const element of value) { - if (typeof element === 'string' && element.length > 0) out.push({ name: element, suffix: '[]' }); - else if (element && typeof element === 'object') { - const named = (element as Record).name; - if (typeof named === 'string' && named.length > 0) out.push({ name: named, suffix: '[].name' }); - } - } - return out; - } - if (value && typeof value === 'object') { - const out: Array<{ name: string; suffix: string }> = []; - const record = value as Record; - for (const key of Object.keys(record)) { - if (key.length > 0) out.push({ name: key, suffix: '{key}' }); - } - const named = record.name; - if (typeof named === 'string' && named.length > 0) out.push({ name: named, suffix: '.name' }); - return out; - } - return []; -} - -/** - * Walk a stored metadata document and report every place it names - * `targetName` through one of `properties`. - * - * `properties` is the derived property → target binding for THIS source type, - * so the walk never guesses; it only looks where the schema said a name lives. - * Paths are reported with array indices and record keys elided (`a.b[].c`, - * `a{}.b`), matching the grammar the previous table used, so the value the - * "Used by" panel renders keeps its established shape. - */ -function collectReferenceHits( - item: unknown, - properties: ReadonlyMap, - targetName: string, -): Array<{ path: string; property: string }> { - const hits: Array<{ path: string; property: string }> = []; - const seenNodes = new Set(); - - const walk = (node: unknown, trail: string, depth: number): void => { - if (!node || typeof node !== 'object' || depth > 16) return; - // Cycle guard. Stored documents are JSON, but a runtime-assembled item - // can still be self-referential, and a scan that hangs is a worse - // answer than a short one. - if (seenNodes.has(node)) return; - seenNodes.add(node); - - if (Array.isArray(node)) { - for (const element of node) walk(element, `${trail}[]`, depth + 1); - return; - } - for (const [key, value] of Object.entries(node as Record)) { - const here = trail ? `${trail}.${key}` : key; - if (properties.has(key)) { - for (const hit of referencedNamesAt(value)) { - if (hit.name === targetName) hits.push({ path: `${here}${hit.suffix}`, property: key }); - } - } - walk(value, here, depth + 1); - } - }; - - walk(item, '', 0); - return hits; -} - -/** - * Every place a stored document names `targetName` as the HEAD of a composite - * record key, through a composite-key site of the derived index - * (`REFERENCE_SITES.compositeKeyByTarget`; the one site today is a permission - * set's `fields`, keyed `.`). - * - * A separate walk, not a branch inside {@link collectReferenceHits}: that walk - * matches a WHOLE value and serves every other site, so it stays as it is. This - * one reads only the record each site names, at the document ROOT where the - * site's premise is derived (`compositeKeySiteHolds`, `./reference-sites.ts`), - * and only its keys. - * - * A key names the target when the text before its FIRST separator equals the - * target's name — the split the security plugin makes for field rules - * (`key.startsWith(object + '.')`), exact because no object name contains a `.`. - * A key with no separator, or one at index 0 (`.full_name`), names no object. - * The field part is never read, so a key whose FIELD part spells the target - * (`invoice.customer`) is not a reference to it. One hit per site: the reported - * path (`fields{key}`) does not carry the key, so a second matching key is the - * same row. - */ -function collectCompositeKeyHeadHits( - item: unknown, - separators: ReadonlyMap, - targetName: string, -): Array<{ path: string; property: string }> { - const hits: Array<{ path: string; property: string }> = []; - if (!item || typeof item !== 'object' || Array.isArray(item)) return hits; - for (const [property, separator] of separators) { - const record = (item as Record)[property]; - if (!record || typeof record !== 'object' || Array.isArray(record)) continue; - for (const key of Object.keys(record)) { - const at = key.indexOf(separator); - if (at > 0 && key.slice(0, at) === targetName) { - hits.push({ path: `${property}{key}`, property }); - break; - } - } - } - return hits; -} - -/** - * Phase 3a-destructive: detect changes between an existing object schema - * and an incoming overlay that would break runtime data — removed fields, - * field type narrowing, required toggled on without a default, a stored - * amount switched to `currencyMode: 'record'` (rows left codeless). Returned - * issues are surfaced as HTTP 409 `destructive_change` unless the caller - * sets `force: true`, letting the admin UI render a warning dialog before - * proceeding. - * - * Scope is intentionally narrow for MVP: covers the most common - * data-loss footguns for `object` and `field` types. Subsequent passes - * can layer in relationship changes, enum-value removals, etc. - */ -/** - * Shallow JSON diff used by `diffMetaItem`. Compares the top-level - * keys of `from` vs `to`; primitive value changes are reported as - * `changed`, nested objects/arrays that differ structurally are also - * reported as a single `changed` entry (deep structural diffs are out - * of scope — Studio renders the full bodies for a side-by-side view). - */ -function diffShallow( - from: Record, - to: Record, -): { - added: Array<{ path: string; value: unknown }>; - removed: Array<{ path: string; value: unknown }>; - changed: Array<{ path: string; from: unknown; to: unknown }>; -} { - const added: Array<{ path: string; value: unknown }> = []; - const removed: Array<{ path: string; value: unknown }> = []; - const changed: Array<{ path: string; from: unknown; to: unknown }> = []; - const fromKeys = new Set(Object.keys(from ?? {})); - const toKeys = new Set(Object.keys(to ?? {})); - for (const k of toKeys) { - if (!fromKeys.has(k)) { - added.push({ path: k, value: (to as any)[k] }); - } else { - const a = (from as any)[k]; - const b = (to as any)[k]; - const aStr = JSON.stringify(a); - const bStr = JSON.stringify(b); - if (aStr !== bStr) { - changed.push({ path: k, from: a, to: b }); - } - } - } - for (const k of fromKeys) { - if (!toKeys.has(k)) { - removed.push({ path: k, value: (from as any)[k] }); - } - } - return { added, removed, changed }; -} - -function detectDestructiveObjectChanges(prev: any, next: any): Array<{ - code: string; - field?: string; - message: string; -}> { - if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') return []; - const prevFields = (prev.fields && typeof prev.fields === 'object') ? prev.fields as Record : {}; - const nextFields = (next.fields && typeof next.fields === 'object') ? next.fields as Record : {}; - - const issues: Array<{ code: string; field?: string; message: string }> = []; - - // [#24411] Every finding below names STORED data a save makes unreachable, - // so a field with no stored column (a `formula`, computed on read — - // ADR-0079 addendum, item 9) loses nothing when it is removed or retyped. - // "Stored" is the spec's storage predicate — the one the sortability - // projection reads — judged from the field's own declaration, ⛔ never a - // type list kept here. A false finding trains callers to send `force=true` - // by reflex, which silences the true one on a stored field. - const isStored = (field: SearchFieldMeta | undefined): boolean => !isVirtualSearchField(field); - - // Removed fields — silently dropping a column is a data-loss event. - for (const fname of Object.keys(prevFields)) { - // Skip system fields — those are managed by applySystemFields and - // re-injected on every registerObject call; they will look "removed" - // in any user-supplied overlay. - if (prevFields[fname]?.system) continue; - if (!isStored(prevFields[fname])) continue; - if (!(fname in nextFields)) { - issues.push({ - code: 'field_removed', - field: fname, - message: `Field '${fname}' removed — existing data in this column will become inaccessible.`, - }); - } - } - - // Field type changes — narrowing or incompatible conversions. - const TYPE_COMPATIBILITY: Record> = { - text: new Set(['textarea', 'markdown', 'html', 'code']), - number: new Set([]), - boolean: new Set([]), - date: new Set(['datetime']), - datetime: new Set(['date']), - }; - for (const fname of Object.keys(nextFields)) { - const prevField = prevFields[fname]; - const nextField = nextFields[fname]; - if (!prevField) continue; // brand-new field — non-destructive - const prevType = prevField.type; - const nextType = nextField.type; - // Judged by what the STORED side loses: an unstored previous field held - // no values, so retyping it into anything starts from an empty column. - if (prevType && nextType && prevType !== nextType && isStored(prevField)) { - const compatible = TYPE_COMPATIBILITY[prevType]?.has(nextType); - if (!compatible) { - issues.push({ - code: 'field_type_change', - field: fname, - message: `Field '${fname}' type changed from '${prevType}' to '${nextType}' — existing values may not convert cleanly.`, - }); - } - } - // required toggled on without a default — new inserts will start - // to fail validation, and any null rows already in the table will - // fail on next save. - if (!prevField.required && nextField.required && nextField.defaultValue === undefined) { - issues.push({ - code: 'field_required_no_default', - field: fname, - message: `Field '${fname}' is now required but has no default value — existing rows with null values may fail validation.`, - }); - } - // [ADR-0144, ruling 6492000994] An existing amount switched to - // `currencyMode: 'record'` — the sibling of `field_required_no_default`: - // a one-word config edit that leaves every stored row owing a value. - // The rows already stored hold an amount and no code; the switch is - // allowed (the write guard refuses such a row only when a write - // touches the pair), so this finding acknowledges it and names the - // explicit backfill. Like its siblings it reads no row: whether the - // table holds any is not this gate's to judge, and a finding over an - // empty table costs one `force`. Judged by what the STORED side holds, - // so a previously unstored field (a formula) raises nothing. - if ( - nextField?.type === 'currency' - && nextField.currencyConfig?.currencyMode === 'record' - && prevField.currencyConfig?.currencyMode !== 'record' - && isStored(prevField) - ) { - const objectName = typeof next.name === 'string' && next.name ? next.name : prev.name; - const codeField = typeof nextField.currencyConfig.currencyField === 'string' - ? `'${nextField.currencyConfig.currencyField}'` - : 'its currency code field'; - issues.push({ - code: 'currency_mode_to_record', - field: fname, - message: `Field '${fname}' switched to currencyMode 'record' — rows already stored with an amount and a blank ${codeField} ` - + 'display as a plain number, fall into the null group of a grouped aggregate, and sit outside every ' - + 'currency-paired filter until they hold a code; once saved, write it with ' - + `\`os migrate record-currency-codes --object ${objectName} --field ${fname} --code \`` - + ' (a dry run; add --apply to write).', - }); - } - } - return issues; -} - -/** - * [commit 82cb6e849] The remedy clause the Phase 3a-destructive refusal ends with — one - * sentence per FACE, because the mechanism that lifts the refusal is not the - * same on every door that raises it. - * - * The refusal is raised in ONE place ({@link ObjectStackProtocolImplementation.saveMetaItem}'s - * Phase 3a-destructive gate) and is quoted onto whatever response the caller's - * catch builds, so the clause used to read `re-submit with ?force=true to - * proceed.` on every face. That is correct prose for exactly one of them — - * `PUT /api/v1/meta/:type/:name`, whose route reads `?force` and threads it - * into the request — and a caller on any OTHER face who does what the sentence - * says gets the identical refusal back. - * - * MEASURED on the face this card was filed on: `POST /packages/:id/duplicate` - * accepts `targetPackageId`, `targetName`, `targetNamespace`, `organizationId` - * and `actor` — no `force`, in the query string or the body — and - * {@link ObjectStackProtocolImplementation.duplicatePackage}'s own request type - * has no `force` field either, so its internal `saveMetaItem` call cannot carry - * one. There is nothing on that door for the caller to set. - * - * ⛔ What is repaired is the CLAUSE, not the door. Giving the duplicate route a - * `force` would widen a public surface and is a contract decision, deliberately - * NOT taken here. Nor is the clause deleted on that face: commit 809e61221 measured that - * `duplicatePackage`'s `failed[].error` is the SOLE carrier of this - * prescription, so deleting the remedy there deletes it from the wire outright. - * Each face therefore states the remedy it actually has. - * - * ⚠️ An absent `face` renders the `?force=true` wording, byte-identical to what - * every face carried before. That default is right on BOTH REST `PUT` doors and - * on nothing else that reaches this gate today — see the `[#11095]` section of - * `protocol.destructive-409-face-inventory.test.ts` for the full inventory. - * - * ## [#11095] The two doors commit 82cb6e849 left open, and why they were split - * - * Commit 82cb6e849's note recorded two further faces that reached this gate and never - * threaded `force`, and filed the disposition as a contract question rather - * than guessing it. The ruling was a SPLIT — one door repaired by threading the - * parameter, the other by telling the truth — and the split is the decision, - * not an accident of convenience: - * - * - `@objectstack/rest`'s compound-name `PUT /meta/:type/:a/:b` now THREADS - * `?force=true`, so the default clause became TRUE there instead of being - * reworded. That half is #7019's ruling applied once more, with its reason: - * the compound route is "word for word the same operation" as its - * single-segment twin — one generic `saveMetaItem` reached by a name spelled - * in two segments — and gating only the single-segment door was MEASURED to - * leave the compound one a bypass of it. Every divergence found between the - * pair since has been closed for that same reason (#6603/#7019's capability - * gate, #8805's write-side organization, #7035's 501 envelope). One door - * reading `?force` and its literal twin ignoring it was the same shape. - * - `@objectstack/runtime`'s dispatcher `PUT /meta` does NOT gain `force`, and - * states `'meta-dispatch'` below instead. It has no twin precedent and a - * different call shape: that branch is reached with a path, a method and a - * body — there is no query string on the transport at all, so `?force=true` - * names a slot that does not exist rather than one that was forgotten. Its - * clause says so, and prescribes what a caller can actually do there. - * - * ⛔ Do not "harmonise" these two later by giving the dispatcher a `force` - * field. The compound door's parity argument is a statement about ONE pair of - * routes that spell one name two ways; it is not a general licence, and the - * dispatcher is not the third member of that pair. - * - * ## [#21841] The external-table import, and why it went the dispatcher's way - * - * "Import as Object" (`@objectstack/service-datasource`) saves through this - * gate since #21788, so a re-import that would shrink an object it created is - * refused here and relayed as `400 EXTERNAL_IMPORT_ERROR`. The import route - * reads no `force`, so the default clause sent that caller round in a circle. - * It states `'external-import'` and gets a clause of its own rather than a - * `force`, for the reasons the dispatcher did and one more: the route has no - * twin that reads `?force`, and no first-party caller re-imports through it - * (the console's import dialog saves its draft through - * `PUT /api/v1/meta/object/:name`, the very door this clause names), so a - * `force` there would be a new capability with nothing pulling on it. Unlike - * the dispatcher, a door that DOES read `?force` exists for the same item, so - * the clause names it instead of only telling the caller to reconcile. - * - * ⛔ Same warning as above: the import is not a twin of the `PUT` door, and - * giving it a `force` to "match" would be a new surface, not a repair. - */ -/** - * [commit 82cb6e849 / commit d806081dd] Which write door a `saveMetaItem` refusal is being - * rendered FOR. Stated by the SERVER — either by the protocol's own internal - * call ({@link ObjectStackProtocolImplementation.duplicatePackage}) or by the - * in-process HTTP boundary that owns the response envelope — never by a remote - * caller: no write door spreads a request body into the `saveMetaItem` request - * object, so there is no path for a client to smuggle a face in (pinned in - * both face-inventory suites). - * - * Two refusals read this, and they read it for DIFFERENT questions: - * - * - {@link destructiveChangeRemedy} (409) — which remedy actually exists on - * this door; - * - {@link specValidationFindings} (422) — whether a structured `issues[]` - * channel reaches the consumer beside the message. - * - * A door therefore answers both questions by naming itself once, and neither - * switch may assume the other's default. `'meta-envelope'` deliberately keeps - * the 409's `?force=true` wording (it is both REST `PUT` doors' genuine remedy, - * since #11095 threaded the parameter on the compound-name twin) while changing - * the 422's clause. - * - * ⚠️ [#11095] `'meta-dispatch'` is the first face that differs from another on - * ONE of the two questions and agrees with it on the other: the runtime - * dispatcher carries `issues[]` structurally exactly as the REST doors do - * (`errorFromThrown` → `details.issues`), so its 422 clause is `'meta-envelope'`'s, - * while its 409 clause must differ because that door has no `force` to name. - * That is the two-switch independence this comment asserts, arriving as a real - * case rather than a hypothetical — which is why {@link specValidationFindings} - * lists the two faces on one `case` instead of letting `'meta-dispatch'` fall - * to a default that was never written for it. - * - * ⚠️ [#21841] `'external-import'` differs from `'meta-dispatch'` on that SAME - * second question, in the other direction: the import route relays a refusal - * as `sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', message)` and nothing else, - * so no `issues[]` reaches its caller and the message is the sole carrier of - * the findings. It therefore stays OUT of {@link specValidationFindings}' - * trimming case and keeps the full-prose default — the polarity that comment - * defends, working as designed for a door added after it. - * - * ⚠️ [ADR-0131 D6] `'package-unmanaged-import'` is stated by - * {@link ObjectStackProtocolImplementation.importUnmanagedPackage}, the - * `unmanaged` install mode's copy-once import, for every item it writes. Its - * refusal is relayed by that verb's own thrown error. The thrown value does - * forward the save door's `issues` (the rollback keeps the cause's code, - * status and issues), but the one door that calls the verb — - * `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'` - * (`@objectstack/cloud-connection`) — answers with the message, the code and - * `details` only, so no `issues[]` reaches its caller beside the message and - * the message is the sole carrier of the findings: it keeps the full-prose - * default on the 422, the polarity `'external-import'` keeps for the same - * reason. A door that starts relaying `issues` moves this face into - * {@link specValidationFindings}' trimming case in the same change. Its 409 - * clause is its own: the import accepts no `force`, and it writes only names - * no environment row held when it began. - */ -export type MetadataWriteFace = - | 'package-duplicate' - | 'meta-envelope' - | 'meta-dispatch' - | 'external-import' - | 'package-unmanaged-import'; - -function destructiveChangeRemedy( - face: MetadataWriteFace | undefined, - name: string, -): string { - switch (face) { - case 'package-duplicate': - // The duplicate-AGAIN workflow: the target namespace already holds - // the renamed item this copy is about to overwrite. Both remedies - // are things the caller can actually do on THIS door — choose a - // free target namespace, or make the collision non-destructive. - return `this copy cannot be forced: the duplicate door accepts no \`force\`. ` - + `Duplicate into a target namespace that does not already hold '${name}', ` - + `or reconcile that item with the source first.`; - case 'meta-dispatch': - // [#11095] The runtime dispatcher's `/meta` PUT. Unlike the two REST - // `PUT` doors — which read `?force` off a query string and, since - // this card, BOTH thread it — this branch is reached with a path, a - // method and a body and nothing else. `?force=true` does not name a - // parameter this door forgot to read; it names a channel the - // transport does not have, which is why threading it here would be - // a new surface rather than a repair, and was ruled out. - // - // Both clauses are things the caller can do on THIS door: send a - // body that does not drop what the stored item still carries, or go - // and reconcile that item first. Same grammar as the duplicate face - // above — name the door, deny the mechanism, then prescribe. - return `this save cannot be forced: the dispatcher's \`PUT /meta\` accepts no \`force\`. ` - + `Re-submit '${name}' with a body that keeps the fields and types named above, ` - + `or reconcile that stored item first.`; - case 'external-import': - // [#21841] "Import as Object" (`@objectstack/service-datasource`), - // relayed by the import route as `400 EXTERNAL_IMPORT_ERROR`. That - // route reads no `force` and was deliberately not given one (see the - // section above `MetadataWriteFace`). Both remedies are things the - // caller can do from where they stand: import the table under a - // name nothing stores yet, or save the changed definition through - // the metadata door, which reads `?force=true` for this very item - // and is where a destructive change is acknowledged on purpose. - // Same grammar as the two faces above — name the door, deny the - // mechanism, then prescribe. - return `this import cannot be forced: the external-table import route accepts no \`force\`. ` - + `Import the table under a new \`name\`, or save the changed definition of '${name}' ` - + `through \`PUT /api/v1/meta/object/${name}?force=true\`, which accepts the destructive change on purpose.`; - case 'package-unmanaged-import': - // [ADR-0131 D6] The `unmanaged` install mode's copy-once import. - // It refuses every name an environment row already holds before - // it writes anything, so reaching this gate means a row appeared - // under the name while the import ran. The import accepts no - // `force`; what the caller can do is reconcile the item that - // appeared, then import again. What became of the base is the - // verb's own sentence around this one, which it writes only once - // it has read the rollback's outcome — never claimed here. Same - // grammar as the faces above — name the door, deny the mechanism, - // then prescribe. - return `this import cannot be forced: the unmanaged install accepts no \`force\`. ` - + `Reconcile the environment item '${name}' that appeared while the import ran, then install again.`; - default: - return 're-submit with ?force=true to proceed.'; - } -} - -/** - * Result of projecting a published metadata body into its data-plane - * representation. `success:false` with an `error` is the surfaced-not-thrown - * failure contract — publishing the metadata itself always succeeds. - */ -export interface PublishMaterializeResult { - success: boolean; - inserted: number; - updated: number; - error?: string; -} - -/** - * Publish-time materializer (ADR-0086 P2). Receives the just-published body - * plus the draft's package binding and org scope. Registered per metadata type - * via {@link ObjectStackProtocolImplementation.registerPublishMaterializer}. - */ -export type PublishMaterializer = (args: { - body: unknown; - packageId: string | null; - organizationId: string | null; - actor: string; -}) => Promise; - -/** - * Uninstall-time data-plane cleanup (ADR-0086 D3, #2747). The exact mirror of - * {@link PublishMaterializer}: domain plugins own data-plane tables the - * protocol layer must not know the shape of (e.g. plugin-security's grant - * table, `sys_user_permission_set`), so they register a named - * cleanup here and {@link ObjectStackProtocolImplementation.deletePackage} - * invokes every cleanup with the uninstalled package id. Cleanups run - * best-effort — a failure is REPORTED on the uninstall response (`cleanups`), - * never thrown — but ghost grants are a security condition, so callers must - * surface a failed cleanup, not swallow it. - */ -export type UninstallCleanup = (args: { - packageId: string; - actor?: string; - /** - * What the package declared, read from the registry just before the door - * withdrew it ({@link captureUninstallDeclared}). Optional: absent when the - * registry did not hold the package or cannot answer, and a cleanup that - * does not read it behaves as before. - */ - declared?: UninstallDeclared; -}) => Promise<{ success: boolean; removed: number; error?: string }>; - -/** - * [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package - * held. A grant names its set, and the package's sets are served from the - * registry, not stored, so after the withdrawal nothing else knows them. - */ -export interface UninstallDeclared { - /** The permission-set names the package held. */ - permission?: string[]; -} - -/** - * Capture what `packageId` declared, for {@link UninstallCleanup}'s `declared` - * — the ONE capture both uninstall doors (`deletePackage` and install-local's - * `DELETE`) call, at the same point: after the uninstall is decided and just - * BEFORE `registry.uninstallPackage`, which releases the names. The withdrawal - * still precedes the cleanups (#21576); only what they are handed grows. - * - * Two sources, unioned: the names the registry says the package holds (an - * installed package's claims and item slots), and the `permission` rows of the - * package's `sys_metadata` the door already read (`stored`). The second is - * what names a writable base's sets (ADR-0070): an item authored into a base - * is stored, and the registry holds it in the bare slot with no `_packageId` - * (provenance `org`, measured), so the registry alone cannot attribute it to - * the base. - * - * `undefined` when neither source names a set — so a package that declared - * none hands its cleanups exactly the request they had before. - */ -export function captureUninstallDeclared( - registry: unknown, - packageId: string, - stored?: ReadonlyArray<{ type?: unknown; name?: unknown } | null | undefined>, -): UninstallDeclared | undefined { - const names = new Set(); - const held = (registry as { securityCatalogNamesHeldBy?: (id: string, type: 'permission') => unknown } | null | undefined) - ?.securityCatalogNamesHeldBy; - if (typeof held === 'function') { - try { - const fromRegistry = held.call(registry, packageId, 'permission'); - if (Array.isArray(fromRegistry)) { - for (const n of fromRegistry) if (typeof n === 'string' && n !== '') names.add(n); - } - } catch { /* the registry could not say; the stored rows still can */ } - } - if (stored) { - for (const row of stored) { - const type = row?.type; - if ((type === 'permission' || type === 'permissions') && typeof row?.name === 'string' && row.name !== '') names.add(row.name); - } - } - return names.size > 0 ? { permission: [...names] } : undefined; -} - -/** Per-cleanup outcome reported on the `deletePackage` response. */ -export interface UninstallCleanupOutcome { - name: string; - success: boolean; - removed: number; - error?: string; - /** - * [#12536] The user-facing refusal text the failing cleanup MARKED with - * `userMessage` (commit 79c46da90) — absent unless it declared one. This outcome is - * response DATA riding inside a `PACKAGE_DELETE_PARTIAL` 400's `details`, - * so no HTTP boundary reads a `userMessage` on its behalf; the channel has - * to exist here or the mark has nowhere to go. Read it with - * `declaredUserMessage`, never by probing the field. - */ - userMessage?: string; -} - -/** - * [#9960] The declared REQUEST shape of - * {@link ObjectStackProtocolImplementation.deletePackage} — the ONE statement - * of this verb's contract, imported by every seam that speaks it. - * - * WHY IT IS NAMED. `deletePackage` has no `packages/spec` declaration (it is - * absent from `PackageProtocol`), and until this type existed its shape was - * stated THREE times, differently, by the three modules that share the seam: - * an inline structural type on the method below; a narrower restatement on - * `PackageRoutesOptions.protocol.deletePackage` in `@objectstack/rest`, which - * named neither `organizationId` nor `keepData`; and no type at all in the - * dispatcher twin (`packages/runtime/src/domains/packages.ts`), which reached - * the verb through `(protocol as any)` and routinely sent exactly the two keys - * the REST restatement could not express. Naming the shape once, HERE at the - * producer, is what makes the other two seams compile-checked against the - * contract instead of each against its own copy. - * - * ⛔ Deliberately NOT declared in `packages/spec`: minting protocol surface for - * a verb with zero external consumers is declare-and-maintain the platform has - * not asked for. Should an external consumer ever appear, the spec declaration - * is its own card for the spec seat, not a rider on a typing convergence. - * - * [ADR-0131 D6/D12] No organization member: an uninstall is environment-wide - * by construction. The `organizationId` and `allTenants` keys that once chose - * its blast radius (#7780) are retired, and `deletePackage` refuses a request - * still carrying either (`INVALID_REQUEST`, 400). - */ -export interface DeletePackageRequest { - packageId: string; - actor?: string; - /** Remove the metadata but PRESERVE each object's physical table. */ - keepData?: boolean; -} - -/** - * [#9960] The declared RESPONSE shape of - * {@link ObjectStackProtocolImplementation.deletePackage}, stated once for the - * same reason as {@link DeletePackageRequest}. - * - * `deleted` is part of it. The REST seam's restatement omitted that member - * entirely, so a caller reading the option's type was told this verb reports - * only a COUNT of what it removed — while the producer has always returned the - * per-item list beside it. - */ -export interface DeletePackageResponse { - success: boolean; - deletedCount: number; - failedCount: number; - deleted: Array<{ type: string; name: string; state: string }>; - /** - * Per-item failures. [#12536] `userMessage` is the mark (commit 79c46da90) the item's - * own failure declared — present exactly when a producer marked its - * refusal, so a caller can tell an application refusal apart from a store - * failure on a path where no HTTP boundary can do it for them. - */ - failed: Array<{ type: string; name: string; error: string; code?: string; userMessage?: string }>; - cleanups: UninstallCleanupOutcome[]; -} - -/** - * Post-persistence metadata-mutation notification (#2588). Emitted by - * `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write - * landed. `type` is the singular metadata type name. Subscribe via - * {@link ObjectStackProtocolImplementation.onMetadataMutation}. - */ -export interface MetadataMutationEvent { - type: string; - name: string; - /** Resulting lifecycle state of the row the mutation produced. */ - state: 'active' | 'draft' | 'deleted'; - organizationId?: string | null; - /** - * [#22877] The bundle member the mutated row belongs to, for a type whose - * identity is a pair (`'zh-CN'` of an `email_template`) — part of the - * row's ADDRESS, so a peer converging on this signal re-reads that - * member's row, not the canonical one. Absent for the canonical member and - * for every type with no members, as on every by-name door. - */ - member?: string; -} - -/** - * [#13331] Cluster channel for post-persistence metadata mutations — the - * cross-node half of {@link MetadataMutationEvent}. - * - * Published by the protocol that performed the write (see - * `attachMetadataMutationPubSub`) and consumed by every peer replica to - * CONVERGE its in-memory registry from its OWN `sys_metadata` read. Distinct - * from `MetadataManager`'s `metadata.changed` channel on purpose: that one - * replays watch events into the metadata SERVICE's caches, while this one - * repairs the ObjectQL engine registry behind the data plane — the state the - * runtime authoring path (`saveMetaItem` → `applyRegistryWriteThrough`) - * mutates without ever touching the metadata service. - */ -export const METADATA_MUTATION_CLUSTER_CHANNEL = 'metadata.mutated'; - -/** - * [#13331] Payload for {@link METADATA_MUTATION_CLUSTER_CHANNEL}. - * - * ⭐ A SIGNAL, never trusted content (ruled 2026-09-01): the receiving replica - * re-reads the row from its own `sys_metadata` and re-runs the registry - * write-through from that read, so the shared database stays the single - * source of truth. `event` carries only the row's ADDRESS (type/name/state/ - * org scope); no body ever rides this channel, which is also what makes - * at-least-once delivery and duplicates harmless by construction — replaying - * the same address converges to the same registry state. - */ -export interface ClusterMetadataMutationPayload { - /** Origin nodeId — used for loopback suppression. */ - originNode?: string; - /** The post-persistence mutation notification, address-only. */ - event: MetadataMutationEvent; -} - -/** - * [#10219] A single item reached `active` through the per-item publish door - * (`publishMetaItem`, i.e. `POST /api/v1/meta/:type/:name/publish`). `type` is - * the CANONICAL singular metadata type name — the same spelling the promoted - * row carries — so a subscriber can build the `'{type}/{name}'` entry the - * `metadata:reloaded` payload's `changed` list is made of. - * - * Subscribe via {@link ObjectStackProtocolImplementation.onMetaItemPublished}. - * - * ## Why this is NOT the existing {@link MetadataMutationEvent} - * - * The per-item publish emits BOTH. The mutation event says "a row changed" and - * is emitted from `runPublishSideEffects`, the phase-2 helper the BATCH door - * (`publishPackageDrafts`) runs once per promoted draft as well — so a - * subscriber that announced a full metadata reload on it would fire N times for - * one "publish whole app", each announce driving a complete re-sync (schema DDL, - * connector re-materialization, flow re-bind) for every other item in the same - * batch. This event is emitted ONLY by the single-item door, which is the one - * the batch route's own announce does not cover. - */ -export interface MetaItemPublishedEvent { - /** Canonical singular metadata type of the promoted item. */ - type: string; - name: string; - /** Scope the promotion landed in — `null` is env-wide. */ - organizationId: string | null; -} - -/** - * Awaited per-type mutation projector (ADR-0094). Invoked AFTER a metadata - * mutation persists — `saveMetaItem` (draft AND active saves), - * `publishMetaItem`, `deleteMetaItem` — and AWAITED before the write returns, - * so a data-plane read-model derived from the metadata (e.g. the - * `datasource` projector's) is already consistent when the caller's next read - * lands. This is what makes such a read-model a PURE projection: the - * projector is its only writer, and it runs in the same awaited operation as - * every metadata write, instead of a fire-and-forget subscriber a new write - * path might race or forget. - * - * Complements (does not replace) {@link MetadataMutationEvent} listeners, - * which stay fire-and-forget for cache-invalidation consumers. - * - * Best-effort: a projector failure is surfaced on the write's response - * (`projectionApplied: { success:false, error }`) and logged, never thrown — - * the metadata write itself already succeeded, and boot reconciliation heals - * the projection on next start. - * - * `body` carries the just-persisted item when the mutation has one in hand - * (save/publish); projectors that need the EFFECTIVE (layered) body should - * re-read it — a delete, for instance, may reveal the artifact baseline. - */ -export type MetadataMutationProjector = ( - evt: MetadataMutationEvent & { body?: unknown }, -) => Promise; - -/** Per-write outcome of the awaited mutation projector (ADR-0094). */ -export interface MutationProjectionOutcome { - success: boolean; - error?: string; -} - -/** - * Pre-persistence authoring gate (ADR-0094 addendum seam; #3050). - * - * Unlike the post-persist {@link MetadataMutationProjector} (best-effort, - * never thrown), an authoring gate runs BEFORE persistence and REJECTS the - * write by throwing — it is the seam for domain invariants that must hold on - * every runtime-authored body regardless of which HTTP surface produced it - * (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN - * a packaged object's `sharingModel`). - * - * Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after - * the ADR-0005 overlay/runtime-create authorization and the per-type spec - * validation — so `publishMetaItem` promotes an already-gated body and needs - * no second gate. Environment writes only: control-plane bootstrap writes - * (`environmentId === undefined`) are the package author's own channel and - * bypass the gate, mirroring the ADR-0005 gate above. - */ -export interface MetadataAuthoringGateContext { - /** Singular type name (e.g. `object`). */ - type: string; - name: string; - /** Lifecycle the body is being saved into. */ - state: 'draft' | 'active'; - /** The body being persisted. */ - body: unknown; - /** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */ - isArtifactBacked: boolean; - /** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */ - declaredBody?: unknown; -} -export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise; - -/** - * [#20790] A metadata type's WRITE-ONLY credential channel — where the - * credentials its bodies carry are stored instead of in the body, on the - * platform's one secret seam (#7799: a `secret`-typed field the engine - * encrypts, masks on every read and dereferences only through - * `resolveSecretField`). Registered per type by the domain plugin that owns - * the type's credential-location table (the automation plugin holds `flow`'s), - * beside its authoring gate. - * - * Every write that lands a body at rest consults it: - * - `saveMetaItem` calls {@link store} immediately before the put, after the - * carry-forward, and persists what it returns; - * - the runtime authoring gate reads {@link heldPaths} as restored positions, - * on an active save and on the draft → active promotion; - * - a restore (rollback, revert) stores {@link strip}'s body. - * - * ⛔ Not a second secret mechanism and not a per-door redaction: the read - * projection stays the type's redactor; this only decides where a credential - * is STORED. - */ -export interface MetadataCredentialChannel { - /** - * Move every explicit credential in `body` into the channel for - * `(name, state)` and return the body without any — what is stored. An - * absent credential means "unchanged". THROWS (with an ADR-0112 - * `code`/`status`) to refuse the save; nothing may have been put then. - */ - store(args: { name: string; state: 'draft' | 'active'; body: unknown }): Promise; - /** - * The positions in `item` (dotted, item-relative — `redactedKeys`' - * spelling) whose credential is withheld from the body and held by the - * channel, so the gate reads them as present. `state: 'draft'` for a draft - * being promoted. - */ - heldPaths(args: { name: string; state: 'draft' | 'active'; item: unknown }): Promise; - /** - * The body a restore stores: every credential removed, and nothing written - * to the channel — it keeps its current credential. - */ - strip(body: unknown): unknown; -} - -/** - * Which authoring channel a kernel's metadata writes arrive on (#6710). - * - * ADR-0005 carves out "the package author's own bootstrap channel" from the - * runtime authoring rules: a control-plane kernel installing a package is not - * an author publishing into a live tenant, and gating it would refuse the very - * bodies the platform ships. The carve-out is legitimate and stays. - * - * What #6710 changed is **how a kernel says it is that channel**. Until this - * type existed the answer was inferred from `environmentId === undefined` — a - * ROW-SCOPING key pressed into service as a topology signal. That proxy lies: - * the CLI's lightweight host-config assembler (`serve.ts`'s auto-register - * branch, `new ObjectQLPlugin()` with no options) also leaves `environmentId` - * undefined, and it serves an END-USER `PUT /api/v1/meta/*`. Both topologies - * read identically, so the whole #4463 gate — all the shared `AUTHORING_RULES` - * — was disengaged on a self-hosted app server. #5086 had already moved the - * code-only refusal off the same proxy for the same reason ("keying - * authorization off a row-scoping key is what made a type-level declaration - * depend on deployment topology; the declaration decides it here instead"). - * - * So the channel is now DECLARED, not inferred: - * - * - `'environment'` — metadata arrives from an author (Studio tenant, MCP/AI - * agent, `PUT /api/v1/meta/*`). The gate runs. **This is the default**, and - * the default is the whole point: an assembly that forgets to declare gets - * MORE enforcement, never less, so the next assembly variant nobody thought - * about cannot silently reopen this hole. - * - `'package-author'` — this kernel IS the package author's own bootstrap - * channel. Only the genuine control-plane assembly may state this. - * - * Deliberately a channel NAME and not a boolean: `skipAuthoringRules: true` - * would be the same bytes with the opposite meaning — a kill switch any - * assembly could reach for to make a red publish go away. A caller has to - * claim to BE the package author to be treated as one. - */ -export type MetadataAuthoringChannel = 'environment' | 'package-author'; - -/** - * One `sys_metadata_audit` row as a VALUE — exactly the entry - * `recordMetadataAudit` writes, named so a refusal can hand its row to whoever - * is in a position to make it durable instead of writing it where it stands. - * - * [#8594] Why the entry became a value. `promoteDraftForPublish` used to audit - * its own denials inline, and it has two callers standing on opposite sides of - * a transaction boundary: `publishMetaItem` runs it in no transaction of its - * own, `publishPackageDrafts` runs it inside ONE `engine.transaction()` for the - * whole batch (ADR-0067 D2). A denial row written inline therefore landed on - * the single-item route and ROLLED BACK on the batch route — destroyed by the - * very rollback its own refusal had caused, which is the defect #7748 exists to - * close. Making the row data lets the helper stay ignorant of whose transaction - * it is inside (it cannot know, and guessing is what broke) and puts the - * placement decision where the transaction actually is: in the caller. - */ -export interface MetadataAuditEntry { - type: string; - name: string; - /** - * [ADR-0131 D6/D7] Always environment-wide: the audit ledger is - * deployment-level, and no metadata write is organization-scoped any more. - * Typed `null` so an organization cannot be recorded by accident. - */ - organizationId?: null; - operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset'; - outcome: 'allowed' | 'denied' | 'forced'; - code: string; - lockState?: MetadataLock; - lockOverridden?: boolean; - actor?: string; - source?: string; - requestId?: string; - note?: string; -} - -/** Attach the denial row `err` owes, and return `err` so call sites read as one `throw`. */ -function withPendingAudit(err: E, audit: MetadataAuditEntry): E { - (err as E & { __pendingAudit?: MetadataAuditEntry }).__pendingAudit = audit; - return err; -} - -/** - * [#8595] The PRE-FLIGHT gate's refusal vocabulary — the closed set of codes - * `publishPackageDrafts` can refuse a batch with BEFORE it opens its - * transaction, and the spelling each one takes in `sys_metadata_audit.code`. + * [#4435/#5138] The 404 a single-record operation answers when the id names no + * row — the repo's ONE `RECORD_NOT_FOUND` envelope. * - * Why a declared map and not `code.toLowerCase()`. `batch_aborted` (#8400) - * deliberately took ONE fixed value because its causal code is whatever the - * error catalog threw, and lower-casing that would turn this column's - * documented closed set into an open one that grows with the catalog. The - * pre-flight codes are a different animal — they are minted in - * `publishPackageDrafts`'s own pre-flight block, not caught from below — so the - * violation's own code CAN be carried honestly, which is what a compliance - * query filters on ("show me every publish refused by the namespace rule"). + * [#7867] The body moved to `@objectstack/core` + * (`utils/record-not-found.ts` — full provenance lives there); this is a + * re-export, so every existing importer of + * `@objectstack/metadata-protocol`'s `recordNotFoundError` is unchanged and + * the three producers still share one function object. * - * The precedent's principle still binds, though: the column must not drift - * silently. Hence `Record` over an index signature — - * a new pre-flight gate (ADR-0121's endpoint gates, should #2657 Part B bring - * them back) cannot reach this column without failing to compile until its - * audit spelling is declared here AND added to the column's documented set in - * `sys-metadata-audit.object.ts`. A mechanical lower-case would have let it - * land silently; a runtime fallback would have put an unreachable value in a - * documented closed set. The compiler is the enforcement. + * ⛔ Do not re-declare it here. It moved because a THIRD producer needed it and + * could not reach this package: `ObjectQL.update()`/`delete()`'s by-id gate + * lives in `packages/objectql`, whose `/core` entry closure is forbidden by + * ADR-0076 D2's boundary ratchet from importing `@objectstack/metadata-protocol` + * at all. A local copy here would be the second spelling #5138 ruled out. */ -type PreflightViolationCode = 'NAMESPACE_PREFIX' | 'STORED_TYPE_NOT_CANONICAL'; - -const PREFLIGHT_AUDIT_CODE: Record = { - // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary - NAMESPACE_PREFIX: 'namespace_prefix', - // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary - STORED_TYPE_NOT_CANONICAL: 'stored_type_not_canonical', -}; +export { recordNotFoundError }; /** - * [#8908] Is this AT-REST `type` a spelling the batch publish must refuse? - * - * ## The class, stated as a predicate rather than a list of four - * - * `publishPackageDrafts` reads `sys_metadata` rows, so its input is a stored - * `type` — not a URL segment, which is why #7894's boundary fold - * ({@link canonicalizeMetaRequestType}) never reaches it. `promoteDraftForPublish` - * folds the stored spelling through `PLURAL_TO_SINGULAR`, the MANIFEST-collection - * map, and that map legitimately omits types which are not stack collections. - * For those, the fold is a NO-OP: the lookup key equals the stored spelling, the - * draft resolves, and the publish mints an ACTIVE row in the very namespace - * `PUT /meta/field/…` answers 403 NOT_OVERRIDABLE for — every registry read and - * every compliance query on `field` missing an item the platform just reported - * as published. - * - * The two limbs below are that sentence, mechanically: - * - * 1. `canonicalMetaType(type) !== type` — the platform's own URL/registry map - * says this spelling names a DIFFERENT type than the one it is stored as, so - * the row lives in a second namespace for an item that has a canonical home. - * A canonical type fails this limb by construction (no canonical spelling - * folds elsewhere — 33 of 33, measured), and so does a plugin-registered or - * unrecognised kind (identity fold), which is what keeps the gate off - * everything that is not this defect. - * 2. the MANIFEST fold leaves it unchanged — i.e. `promoteDraftForPublish` - * would look the row up under its stored spelling and FIND it. - * - * ⛔ Limb 2 is not decoration and must not be dropped for "one simpler rule". - * A manifest-PRESENT plural (`objects`) is already fail-closed: the promote - * addresses the row by the folded singular, `whereFor` emits that spelling with - * no at-rest fallback, and the batch aborts on `NO_DRAFT` — pinned, with its - * reasoning, in `protocol.publish-side-effects-canonical-type.test.ts`. Widening - * this gate over that class would change a wire-visible `failed[].code` for rows - * that are NOT this defect. #8908's ruling names one class; this predicate is it. + * The keys THIS file stamps onto every served document (`_diagnostics` via + * `decorateMetadataItem`, `_draft` via the draft-preview overlay) — and the + * strip that keeps them out of a persisted body (#4326) or a strict re-parse + * (cloud#971). * - * ## Why a predicate and not the four names the card lists + * The list itself lives in `@objectstack/spec` because this module PRODUCES the + * decoration while consumers in other layers (`service-automation`'s cold-boot + * flow bind, …) have to REMOVE it: one shared definition is what stops the two + * sides from drifting. See `spec/kernel/metadata-read-decorations.ts` for the + * full rationale and for why the ADR-0010 protection envelope (`_lock`, + * `_packageId`, …) is deliberately not stripped despite the shared spelling. * - * The card names `fields`, `seeds`, `external_catalogs`, `translations`. Measured - * against the real maps, the class is SIX spellings: those four plus - * `externalCatalogs` (the camelCase limb of the same type) and `email_templates` - * (a registry type whose snake_case plural the manifest map also omits, while - * carrying `emailTemplates`). A hand-written list would have shipped with two - * members missing and no way to notice; this derivation cannot drift from the - * maps it reads, and a newly declared type that never reaches the manifest map - * is covered on the day it is declared. - */ -function isNonCanonicalStoredType(type: string): boolean { - return canonicalMetaType(type) !== type && (PLURAL_TO_SINGULAR[type] ?? type) === type; -} - -/** - * [#21442] A stored `sys_metadata` row as the list read parses it: its own - * name, its body (stored-row conversions replayed), and the package and - * organization it is bound to (`organizationId: null` for an environment-wide - * row). [#21804] `type` is the spelling the row is stored under (its own - * `type` column), which the served-row resolution reads. - */ -export interface StoredOverlayEntry { - name: string; - data: any; - packageId: string | undefined; - organizationId: string | null; - type: string; -} - -/** - * [#28412] A parsed stored row as {@link ObjectStackProtocolImplementation.declinesStoredRow} - * judges it: its stored type spelling, its `package_id` column (the entry's - * `packageId`, never the body's `_packageId`) and its parsed body. - */ -function storedEntryRow(entry: Pick): ServedOverrideRow { - return { type: entry.type, package_id: entry.packageId ?? null, metadata: entry.data }; -} - -/** - * [#21442] A row-less view name a stored view container expands: the item the - * list read serves under the name, and the stored container row it derives - * from. - */ -export interface RowlessExpandedView { - item: Record; - container: StoredOverlayEntry; -} - -/* - * [#28355] The request shapes of the four single-item doors. Each door is a - * public wrapper (refusal audit) over a private body, and both read the shape - * from here: a private member's type is not published, so a wrapper typed off - * its body would publish an untyped `request`. Deliberately not exported — the - * published signatures are what they were, under these local names. + * Re-exported here so `@objectstack/metadata-protocol`'s public surface is + * unchanged. */ - -/** The request of {@link ObjectStackProtocolImplementation.saveMetaItem}. */ -type SaveMetaItemRequest = { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient }; - -/** The request of {@link ObjectStackProtocolImplementation.publishMetaItem}. */ -type PublishMetaItemRequest = { - type: string; - name: string; - actor?: string; - message?: string; - /** - * [commit 490879ad0] ADR-0048 — the software package the draft being promoted was - * listed under, when the caller has one to state. Forwarded whole to - * {@link promoteDraftForPublish}, which threads it into BOTH the #9612 - * gate closure and `repo.promoteDraft`, so the gate and the write - * resolve the draft under the SAME key it was listed by. - * - * Declared because it is REAL on this door, not merely tolerated: - * since commit 9e04c3e35 `POST /meta/:type/:name/publish?package=PKG_ID` states it - * on every HTTP-driven promotion that names a package — which is - * Studio's designer save-then-publish loop. Until it was declared the - * value flowed correctly but was invisible to every typed caller, and - * the only caller that states one reaches this method through a cast, - * so the binding was enforced by nothing. Commit 490879ad0 added the pins in - * `protocol-publish-drafts-package-scope.test.ts`. - * - * ⚠️ `null` is NOT the same as absent, and the difference is load - * bearing the whole way down: {@link promoteDraftForPublish} branches on - * the KEY BEING PRESENT (`'packageId' in request`), so an ABSENT key - * names no package while `null` pins the lookup to the UNBOUND row. - * [#23495] Naming no package is no longer "match any package": the - * publish promotes the draft a draft save naming no package writes - * ({@link packagelessDraftAddress}), and is refused `409 - * RESOURCE_CONFLICT` when the item's active rows are bound to two or - * more packages. Spread it in conditionally; never write - * `packageId: maybeUndefined`, which arrives as a present-and-undefined - * key, coerces to `null` downstream, and makes a package-bound draft - * unfindable — a silent `no_draft` on the untouched path. - */ - packageId?: string | null; - // [commit 490879ad0] `environmentId` is deliberately NOT declared here, although - // the REST door spreads it into this very request literal. It is the - // multi-kernel ROUTING key, and it is out of the protocol request shape - // by explicit maintainer ruling (recorded 2026-08-18, landed as commit 2a29caa53): - // `resolveProtocol(environmentId)` has already selected the target - // kernel before this method is entered, and this class reads its - // environment off the INSTANCE (`this.environmentId`, set at - // construction) and never off a request — measured, `request.environmentId` - // occurs nowhere in this file. `packages/rest` declares that one - // transport-level member on top of the declared shape instead - // (`TransportScopedMetaRequest`), which is where a routing key belongs. - // Adding it here would reverse that ruling rather than record it. - /** - * INTERNAL — `publishPackageDrafts` publishes many drafts and batch-applies - * every seed body in ONE loader pass afterwards (cross-seed references need - * multi-pass over the whole set), so it suppresses the per-item apply here. - */ - _skipSeedApply?: boolean; - /** [#22877] The bundle member whose draft to publish — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ - member?: string; -}; - -/** The request of {@link ObjectStackProtocolImplementation.rollbackMetaItem}. */ -type RollbackMetaItemRequest = { - type: string; - name: string; - toVersion: number; - actor?: string; - message?: string; - /** - * [#22877] The bundle member to revert — {@link resolveBundleMemberSelector}; - * omitted, the canonical one. The bundle shares one lineage counter, so - * a version of ANOTHER member answers 404 `VERSION_NOT_FOUND`. - */ - member?: string; -}; - -/** The request of {@link ObjectStackProtocolImplementation.deleteMetaItem}. */ -type DeleteMetaItemRequest = { - type: string; - name: string; - parentVersion?: string | null; - actor?: string; - state?: 'active' | 'draft'; - /** - * When true, also drop the object's physical table after the metadata - * is removed (object + active only; never `sys_`). Default false keeps - * delete non-destructive to data. Used by the "discard a previewed - * object" flow so a publish-to-preview leaves no orphan table. - * [#23415] Only with the object's last stored row: while another row - * of it stays stored, the delete is refused `409 RESOURCE_CONFLICT` - * ({@link dropStorageRemainingRowsRefusal}). - */ - dropStorage?: boolean; - /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ - member?: string; - /** - * [#23165, ADR-0048] The package whose row this delete removes, as a - * save names the row it writes (`?package=` on the REST door). Named, - * the delete removes that package's row and no other. Absent or `null` - * names no package: the delete removes the row of the one package the - * item's rows at its state are bound to, is refused `409 - * RESOURCE_CONFLICT` when they are bound to two or more, and with none - * removes the package-unbound row ({@link packagelessAddress}). - * [#23495] A draft discard naming none removes the draft a draft save - * naming none writes ({@link packagelessDraftAddress}), the one the - * per-item publish naming none promotes. - */ - packageId?: string | null; -}; +export { stripReadDecorations }; /** * Implements the per-domain contracts this class ACTUALLY provides (ADR-0076 From 214cfbb32ada8cc981ef995087ef824641794fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:37:48 +0000 Subject: [PATCH 2/8] refactor(metadata-protocol): import the E0 modules in the order the rolled-up .d.ts keeps The module import order in protocol.ts decides the declaration order of the published index.d.ts; this order keeps it equal to the base's. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a8a1ff2ae92..a0a204da076 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -455,7 +455,8 @@ import { withRefusalAudit } from './protocol-refusal-audit.js'; // [#28665] The null-master rows a `controlled_by_parent` detail stops serving, counted into the finding that makes its master required. import { withNullMasterRowCounts } from './protocol-destructive-null-master.js'; // [#25467 E0] The module preamble that stood here moved, verbatim, into the themed -// `protocol-*.ts` modules below. What this file exported from it, it still exports. +// `protocol-*.ts` modules below. What this file exported from it, it still exports; +// the module order below is the order the rolled-up `.d.ts` keeps their declarations in. import { HAND_CRAFTED_SCHEMAS, TYPE_TO_FORM, @@ -595,15 +596,6 @@ export { toRowApiError, } from './protocol-client-failures.js'; export type { BatchDataLoopOutcome, BatchDataRowResult } from './protocol-client-failures.js'; -import { - destructiveChangeRemedy, - detectDestructiveObjectChanges, - diffShallow, - specValidationFindings, -} from './protocol-write-face.js'; -import type { MetadataWriteFace } from './protocol-write-face.js'; -export { specValidationFindings } from './protocol-write-face.js'; -export type { MetadataWriteFace } from './protocol-write-face.js'; import { RESERVED_LIST_QUERY_PARAMS, assertQueryParamArity, @@ -631,6 +623,15 @@ export { export type { StoredMetadataSearchSchema } from './protocol-list-query-params.js'; import { SERVICE_CONFIG } from './protocol-discovery-services.js'; import { collectCompositeKeyHeadHits, collectReferenceHits } from './protocol-reference-hits.js'; +import { + destructiveChangeRemedy, + detectDestructiveObjectChanges, + diffShallow, + specValidationFindings, +} from './protocol-write-face.js'; +import type { MetadataWriteFace } from './protocol-write-face.js'; +export { specValidationFindings } from './protocol-write-face.js'; +export type { MetadataWriteFace } from './protocol-write-face.js'; import type { DeletePackageRequest, DeletePackageResponse, From 4bf750145f743820f834b6a58b4fa35b171f9801 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:45:31 +0000 Subject: [PATCH 3/8] test(metadata-protocol): the two by-path pins read the moved preamble from its module protocol.lookup-artifact-item-call-sites reads envelopePackageId's one definition from protocol-package-bindings.ts and asserts protocol.ts imports it (no copy); protocol.read-verb-canonical-fold reads the four door request aliases from protocol-door-requests.ts when protocol.ts imports them from there. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- ...col.lookup-artifact-item-call-sites.test.ts | 12 +++++++++++- .../protocol.read-verb-canonical-fold.test.ts | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index ccf46dd59b0..06bc819c9f8 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -44,6 +44,12 @@ const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.u const SEAM_FILE = 'protocol-artifact-lookup.ts'; const SEAM = readFileSync(fileURLToPath(new URL(`./${SEAM_FILE}`, import.meta.url)), 'utf8'); const CALLEE = 'lookupArtifactItem('; +/** + * [#25467 E0] The module the protocol.ts preamble's package-binding helpers moved + * to, `envelopePackageId` among them; protocol.ts imports the one definition. + */ +const BINDINGS_FILE = 'protocol-package-bindings.ts'; +const BINDINGS = readFileSync(fileURLToPath(new URL(`./${BINDINGS_FILE}`, import.meta.url)), 'utf8'); /** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */ const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; @@ -183,7 +189,11 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo }); it('both read doors take their envelope from one rule, the served item\'s own package when none is named', () => { - expect(SOURCE).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/); + // [#25467 E0] The rule is defined once, in the bindings module, and protocol.ts + // imports that definition rather than carrying a copy of its own. + expect(BINDINGS).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/); + expect(SOURCE).not.toMatch(/\nfunction envelopePackageId\(/); + expect(SOURCE).toMatch(/\nimport \{[^}]*\benvelopePackageId,[^}]*\} from '\.\/protocol-package-bindings\.js';/); expect(SOURCE).toContain('const itemPackageId = envelopePackageId(packageId, it);'); expect(SITES.filter((site) => site.disposition === 'served-package envelope').map((site) => site.member).sort()) .toEqual(['readFlattenedMetaItems', 'readMetaItem']); diff --git a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts index af732f3d19a..cee9948c53d 100644 --- a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts +++ b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts @@ -247,14 +247,22 @@ describe('#9157 — the population, re-derived from the code rather than from th return lines.slice(line, end === undefined || end < 0 ? lines.length : end).join('\n'); }; const METHOD_OR_PRIVATE = /^ {4}(?:private |protected )?(?:static )?(?:async )?[A-Za-z_][A-Za-z0-9_]*\s*\(/; + // [#25467 E0] The four door request aliases moved with the rest of the + // protocol.ts preamble to `protocol-door-requests.ts`; an alias is read + // there only when protocol.ts imports it from that module, so the + // signature judged is still the one the class names. + const doorRequests = readFileSync(fileURLToPath(new URL('./protocol-door-requests.ts', import.meta.url)), 'utf8'); + const doorImport = /\nimport type \{([^}]*)\} from '\.\/protocol-door-requests\.js';/.exec(src)?.[1] ?? ''; const aliasLiteral = (name: string): string | undefined => { - const at = src.indexOf(`\ntype ${name} = {`); + const home = src.includes(`\ntype ${name} = {`) ? src + : new RegExp(`\\b${name}\\b`).test(doorImport) ? doorRequests : src; + const at = home.indexOf(`\ntype ${name} = {`); if (at < 0) return undefined; - const open = src.indexOf('{', at); + const open = home.indexOf('{', at); let depth = 0; - for (let i = open; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}' && --depth === 0) return src.slice(open, i + 1); + for (let i = open; i < home.length; i++) { + if (home[i] === '{') depth++; + else if (home[i] === '}' && --depth === 0) return home.slice(open, i + 1); } return undefined; }; From e7aed1629816a9ce3979dff082a9f9f61df2e596 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:49:35 +0000 Subject: [PATCH 4/8] chore(changeset): patch for the E0 preamble move in @objectstack/metadata-protocol Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .changeset/25467-e0-protocol-preamble-modules.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/25467-e0-protocol-preamble-modules.md diff --git a/.changeset/25467-e0-protocol-preamble-modules.md b/.changeset/25467-e0-protocol-preamble-modules.md new file mode 100644 index 00000000000..304d2a493be --- /dev/null +++ b/.changeset/25467-e0-protocol-preamble-modules.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the module preamble of `protocol.ts` moves, verbatim, into 22 themed `protocol-*.ts` modules + +Clause-②: no +Metadata-protocol: none + +A move with no behaviour change. Every top-level declaration that stood above `ObjectStackProtocolImplementation` in `src/protocol.ts` (183 statements: helpers, error classes, the query-parameter and filter vocabulary, request and event types) now lives in a themed module beside it, byte for byte with its comments. `protocol.ts` re-exports every name it exported, so `@objectstack/metadata-protocol`'s entry is unchanged: the published `index.d.ts` keeps the same declarations in the same order and the same ESM and CJS export list, and differs only in blank lines, the order of names in one `@objectstack/spec/api` import list and the member order of one literal union. Nothing to migrate. From 6b1dd49e2b8354671d21b404f1f9c689036b2b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:31:32 +0000 Subject: [PATCH 5/8] docs(qa,spec): re-point three symbol citations at the modules the protocol.ts preamble moved to diffShallow (protocol-write-face.ts), protectionBlockRefusal (protocol-authoring-refusals.ts) in the studio-authoring checklist area, and QUERY_AST_KEYS / WIRE_DOLLAR_ALIASES (protocol-list-query-params.ts) in the spec liveness ledger's query evidence. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- docs/qa/platform-checklist/areas/studio-authoring.json | 6 +++--- packages/spec/liveness/query.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index b4627b221a3..8e98a16e661 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -430,7 +430,7 @@ "package-wide door: stage two drafts in the writable package and POST /api/v1/packages//publish-drafts — both flip in one atomic release (the audit's 'Published all drafts in this package (one atomic release)' toast); capture the STATUS and the BODY separately, because the abort path answers 200 with data.success:false (see the clause below)", "author an INVALID draft (a widget carrying a stray legacy key) and attempt its publish — the author-time gate must reject the draft→active transition (#4463)", "meta forensics (after the two publishes of qa_lifecycle_probe — revision 1, then the second revision): GET /api/v1/meta/dashboard/qa_lifecycle_probe/history — the durable sys_metadata_history events must list BOTH published revisions (a dashboard is an overlay type, so history is real; a non-overlay type answers { events: [] } by design). History counts draft saves AND publishes, so two save-draft-then-publish rounds read v1 (draft), v2 (its publish), v3 (draft), v4 (publish): read the two PUBLISH versions off this body for the diff step, steps[9] (QA run #23166 read v2 and v4)", - "GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=&to= — the two publish versions read off /history in steps[8] (v2 and v4 on a clean two-round run; QA run #26715 read v2 and v5, because one extra If-Match draft save sat between them); the diff is SHALLOW by design — it names the changed TOP-LEVEL key (`widgets`, with its whole before/after value) and only the keys that differ, never the whole body and never a path inside the array (packages/metadata-protocol/src/protocol.ts#diffShallow: deep structural diffs are out of scope, Studio renders the full bodies side by side). ⛔ Not ?from=1&to=2: v1 → v2 is a draft save and its own publish, which differ in nothing (changed []), and a default previous-vs-current read can land on the same draft/publish pair", + "GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=&to= — the two publish versions read off /history in steps[8] (v2 and v4 on a clean two-round run; QA run #26715 read v2 and v5, because one extra If-Match draft save sat between them); the diff is SHALLOW by design — it names the changed TOP-LEVEL key (`widgets`, with its whole before/after value) and only the keys that differ, never the whole body and never a path inside the array (packages/metadata-protocol/src/protocol-write-face.ts#diffShallow: deep structural diffs are out of scope, Studio renders the full bodies side by side). ⛔ Not ?from=1&to=2: v1 → v2 is a draft save and its own publish, which differ in nothing (changed []), and a default previous-vs-current read can land on the same draft/publish pair", "POST /api/v1/meta/dashboard/qa_lifecycle_probe/rollback with { toVersion: 1 }, driven under an authenticated session so the actor is attributable — the authenticated identity is the ONLY source ([#7941]; resolveMetaWriteActor no longer consults `X-Actor` at all); the runtime GET must then serve revision 1's body again and the rendered dashboard must show revision 1's widget", "repeat the rollback (or another /meta write on this item, e.g. a follow-up draft save) while ALSO sending header `X-Actor: someone_else_entirely` on the authenticated request — the header must NOT change the recorded actor: the audit/history rows for that action still name the authenticated runner's own identity, never `someone_else_entirely` ([#7941] — pinned at the unit layer in packages/rest/src/meta-write-actor-identity.test.ts)", "attempt a rollback with a missing/invalid toVersion and capture the 400 INVALID_REQUEST guard", @@ -488,7 +488,7 @@ { "clause": "diff names the changed top-level key, not a whole-body dump — GET .../diff between the two PUBLISH versions read off /history (v2 → v4 on a clean run) reports `widgets` under changed[] with its before and after values, and no top-level key whose value did not change", "oracle": "api", - "verify": "changed[] carries an entry with path 'widgets' (the top-level key the second revision edited) whose from/to carry the two arrays, and no entry for a top-level key whose value is the same in both revisions; the diff is shallow by contract (protocol.ts#diffShallow), so a path inside the array (widgets[0].title) is NOT expected and its absence is not a FAIL, while an unchanged key listed, or the whole document under one path, is. A diff of a draft save against its own publish (from=1&to=2) reports changed [] and scores nothing either way", + "verify": "changed[] carries an entry with path 'widgets' (the top-level key the second revision edited) whose from/to carry the two arrays, and no entry for a top-level key whose value is the same in both revisions; the diff is shallow by contract (protocol-write-face.ts#diffShallow), so a path inside the array (widgets[0].title) is NOT expected and its absence is not a FAIL, while an unchanged key listed, or the whole document under one path, is. A diff of a draft save against its own publish (from=1&to=2) reports changed [] and scores nothing either way", "evidence": "the diff response" }, { @@ -1993,7 +1993,7 @@ }, "source": [ "packages/metadata-protocol/src/runtime-authoring-gate.ts", - "packages/metadata-protocol/src/protocol.ts#protectionBlockRefusal", + "packages/metadata-protocol/src/protocol-authoring-refusals.ts#protectionBlockRefusal", "packages/spec/src/ui/page.zod.ts#PAGE_SLOTS_TABS_RETIRED", "packages/spec/src/ui/view.zod.ts#checkListViewChartBinding", "packages/lint/src/validate-expressions.ts#validateStackExpressions", diff --git a/packages/spec/liveness/query.json b/packages/spec/liveness/query.json index 4141135a06e..8930adcd106 100644 --- a/packages/spec/liveness/query.json +++ b/packages/spec/liveness/query.json @@ -5,7 +5,7 @@ "object": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/objectql/src/engine.ts#fillQueryAstDefaults (`const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }` — the resolved name is spread LAST so a stray body `object` cannot split `ast.object` from the table actually read); packages/metadata-protocol/src/protocol.ts#QUERY_AST_KEYS (reserves it at the REST boundary so a POST body's `object` is never read as a field filter, and a body/route mismatch is refused rather than resolved by picking a winner)", + "evidence": "packages/objectql/src/engine.ts#fillQueryAstDefaults (`const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }` — the resolved name is spread LAST so a stray body `object` cannot split `ast.object` from the table actually read); packages/metadata-protocol/src/protocol-list-query-params.ts#QUERY_AST_KEYS (reserves it at the REST boundary so a POST body's `object` is never read as a field filter, and a body/route mismatch is refused rather than resolved by picking a winner)", "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `protocol.ts:780` had rotted onto `if (seen.has(key)) return;` in an unrelated walker; `QUERY_AST_KEYS` is at ~:2426 today, ~1,650 lines away, in a 20,255-line file. The engine leg was PATH-ONLY (`engine.ts` with no line), which the line bound cannot falsify at all — it is now the narrowest symbol that assembles the AST. Re-closed by hand against 93ea19bca." }, "fields": { @@ -53,7 +53,7 @@ "top": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/objectql/src/engine.ts#ENGINE_QUERY_SLOTS (the engine's slot set — the `where` and `limit` canonicals, of which `top` is the OData alias); packages/objectql/src/engine.ts#foldEngineOptionAliases (applied on `find`/`findOne` before anything reads the AST, so the driver only ever sees `limit`); packages/metadata-protocol/src/protocol.ts#WIRE_DOLLAR_ALIASES (`['$top', 'top']` — the wire spelling folded on the REST list path)", + "evidence": "packages/objectql/src/engine.ts#ENGINE_QUERY_SLOTS (the engine's slot set — the `where` and `limit` canonicals, of which `top` is the OData alias); packages/objectql/src/engine.ts#foldEngineOptionAliases (applied on `find`/`findOne` before anything reads the AST, so the driver only ever sees `limit`); packages/metadata-protocol/src/protocol-list-query-params.ts#WIRE_DOLLAR_ALIASES (`['$top', 'top']` — the wire spelling folded on the REST list path)", "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41), REPOINTED, and the MECHANISM had moved too — this is the one entry in the batch where re-closing the call graph changed the story rather than just the coordinates. `engine.ts:2850-2853` claimed an inline `top → limit` normalization; both citations had rotted onto docblocks (`:2853` in engine.ts, `:3486` in protocol.ts), and the inline fold no longer exists: the alias now lives in the spec's ONE alias table (`RPC_QUERY_ALIAS_SLOTS`), filtered into `ENGINE_QUERY_SLOTS` and applied by `foldEngineOptionAliases` at every engine entry point (#4346/#4371). The verdict `live` was never in doubt; the pointer had stopped describing how. Re-closed by hand against 93ea19bca." }, "cursor": { From c40e9ccc6a24394e25d48be930fbf7918f916e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:31:55 +0000 Subject: [PATCH 6/8] chore(changeset): @objectstack/spec patch for its shipped liveness ledger citations Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .changeset/25467-e0-protocol-preamble-modules.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/25467-e0-protocol-preamble-modules.md b/.changeset/25467-e0-protocol-preamble-modules.md index 304d2a493be..b4ce20bf125 100644 --- a/.changeset/25467-e0-protocol-preamble-modules.md +++ b/.changeset/25467-e0-protocol-preamble-modules.md @@ -1,5 +1,6 @@ --- '@objectstack/metadata-protocol': patch +'@objectstack/spec': patch --- refactor(metadata-protocol): the module preamble of `protocol.ts` moves, verbatim, into 22 themed `protocol-*.ts` modules @@ -8,3 +9,5 @@ Clause-②: no Metadata-protocol: none A move with no behaviour change. Every top-level declaration that stood above `ObjectStackProtocolImplementation` in `src/protocol.ts` (183 statements: helpers, error classes, the query-parameter and filter vocabulary, request and event types) now lives in a themed module beside it, byte for byte with its comments. `protocol.ts` re-exports every name it exported, so `@objectstack/metadata-protocol`'s entry is unchanged: the published `index.d.ts` keeps the same declarations in the same order and the same ESM and CJS export list, and differs only in blank lines, the order of names in one `@objectstack/spec/api` import list and the member order of one literal union. Nothing to migrate. + +`@objectstack/spec` ships its `liveness/` ledger: two evidence citations in `liveness/query.json` (`QUERY_AST_KEYS`, `WIRE_DOLLAR_ALIASES`) now name `protocol-list-query-params.ts`, the module those lines moved to. No schema, type or export of `@objectstack/spec` changes. From 20d8c2c813765556489c04d8da4a702f71d661ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:47:00 +0000 Subject: [PATCH 7/8] refactor(metadata-protocol): name the declaration-emit offset trap in protocol.ts's E0 note Two more comment lines. They also move protocol.ts's byte offsets off the place where, after the merge of main, the emitter printed a class JSDoc inside getMetaTypes()'s inferred return type in the published index.d.ts. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index be5c538e545..5bebcaf3919 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -458,6 +458,8 @@ import { withNullMasterRowCounts } from './protocol-destructive-null-master.js'; // [#25467 E0] The module preamble that stood here moved, verbatim, into the themed // `protocol-*.ts` modules below. What this file exported from it, it still exports; // the module order below is the order the rolled-up `.d.ts` keeps their declarations in. +// A byte-offset shift in this file can surface a class comment inside an inferred type +// of the published `.d.ts`: read its raw diff after any edit here. import { HAND_CRAFTED_SCHEMAS, TYPE_TO_FORM, From fb09f70ad757bee2e4137df6c3029d242832ca26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:37:06 +0000 Subject: [PATCH 8/8] refactor(metadata-protocol): carry the import-attached comments and doc-link imports the preamble move dropped The move kept protocol.ts's imports by the identifiers its code uses, so three comments attached to imports it dropped or moved went nowhere, and seven import names that only the class's {@link} doc references use were removed. Restored, each at its base place: - the [#19295] block above `markErasedAuthoringInput`'s import, now in protocol-meta-type-schema.ts; - the [#21207] inline note with `ephemeralStoredHashDigest` in the metadata-redaction import; - the [#5488] note after the reference-sites import, where it stood; - SDUI_MANIFEST_SERVICE, backfillOverlayBundleMembers, unboundItemDraftAddress, resolveItemLockLazily, REFERENCE_VALUE_TYPES, externalBindingDatasourceRefusal and ephemeralStoredHashDigest back in protocol.ts's import lists. Comments and import lists only. The published index.d.ts is byte-identical to the previous head's. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .../src/protocol-meta-type-schema.ts | 5 +++++ packages/metadata-protocol/src/protocol.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/metadata-protocol/src/protocol-meta-type-schema.ts b/packages/metadata-protocol/src/protocol-meta-type-schema.ts index 73f33c45666..0d3d9a6f629 100644 --- a/packages/metadata-protocol/src/protocol-meta-type-schema.ts +++ b/packages/metadata-protocol/src/protocol-meta-type-schema.ts @@ -12,6 +12,11 @@ // `{ not: {} }` — is dropped from it. See the module header for the channels // that keep carrying the retirement's prescription. import { stripUnauthorableProperties } from './unauthorable-nodes.js'; +// [#19295] The output derivation of a `ZodPipe` erases the arm's own input +// type, so an authoring arm that accepts a bare string is served as the +// anonymous `{}`. This annotates that husk — and only that husk — so a +// consumer can tell an ERASED authoring type from a member that genuinely +// admits anything. See the module header for the predicate and its controls. import { markErasedAuthoringInput } from './erased-authoring-mark.js'; import { applyAuditFieldGovernance, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5bebcaf3919..60e9521ab59 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16,6 +16,7 @@ import type { MetadataHostEngine } from './host-engine.js'; import { omitInternalFieldsFromWriteResponse } from './write-response-internal-fields.js'; import { staleSummaryRefusalOrThrow, withEarnedAnswer, type SummaryRecomputeRefusal } from '@objectstack/types'; import { + SDUI_MANIFEST_SERVICE, stampHtmlPageRequires, type RuntimePendingDeclarations, } from './runtime-authoring-gate.js'; @@ -23,6 +24,7 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. import { + backfillOverlayBundleMembers, overlayMemberWhere, } from './migrations/overlay-index.js'; import { @@ -33,12 +35,14 @@ import { packageScopedRowWhere, packagelessAddressAmbiguityRefusal, resetEnvWritableMetadataTypes, + unboundItemDraftAddress, versionNotFoundError, type SysMetadataEngine, } from './sys-metadata-repository.js'; import { managedItemSealedSentence, packagedBaseRegimeRow, packagedBaseRegimeSentence } from './packaged-base-regime.js'; import { resolveItemLock, + resolveItemLockLazily, resolveOverlayLockLayer, storedRowDocument, withItemLockFamily, @@ -115,6 +119,7 @@ import { authoringClientOf, storedDraftChanges } from './draft-changes.js'; import { parseFilterAST, isFilterAST, + REFERENCE_VALUE_TYPES, foldQueryAliasSlots, QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, @@ -122,6 +127,7 @@ import { type DroppedFieldsEvent, type EngineQueryOptionsParsed, type ValidationAdvisoryEvent, + externalBindingDatasourceRefusal, } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; // [#13331] The cluster fan-out transport type only — the protocol never @@ -180,6 +186,15 @@ import { import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import { REFERENCE_SITES } from './reference-sites.js'; +// [#5488] The `@objectstack/spec/api` import that stood here — `ApiEndpointSchema`, +// `validateApiEndpointDeclarations`, `type ApiEndpoint` — went with +// `gateApiDraftsForPublish` (see its retirement note in `publishPackageDrafts`). +// This module no longer judges endpoints at all: `api` is code-only since #5488, +// so no `api` draft can be authored here, and the ONE judge of what is servable +// stays `validateApiEndpointDeclarations` on the artifact route (stack schema, +// `publishPackage` #5189, and `buildEndpointIndex` at load, PR #5203). An +// endpoint-shaped import left behind with no caller reads as a capability this +// module still has (#3950), so it is removed rather than parked. import { computeMetadataDiagnostics, computeViewReferenceDiagnostics, @@ -204,6 +219,9 @@ import { storedMetadataBodyGroupingRefusal, storedMetadataBodyPredicateRefusal, storedMetadataBodyProjection, + // [#21207] The stored content hash of the same rows: served keyed, never + // evaluated — see `STORED_METADATA_HASH_COLUMNS`. + ephemeralStoredHashDigest, servedContentHash, serveStoredHashTokens, serveStoredMetadataHashColumnRows,