diff --git a/.changeset/25467-e0-protocol-preamble-modules.md b/.changeset/25467-e0-protocol-preamble-modules.md new file mode 100644 index 00000000000..b4ce20bf125 --- /dev/null +++ b/.changeset/25467-e0-protocol-preamble-modules.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/spec': 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. + +`@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. 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/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..0d3d9a6f629 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-meta-type-schema.ts @@ -0,0 +1,625 @@ +// 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'; +// [#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, + // [#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.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; }; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0bfc00c3773..60e9521ab59 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1,28 +1,20 @@ // 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, @@ -33,8 +25,6 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. import { backfillOverlayBundleMembers, - overlayMemberOfBody, - overlayMemberValue, overlayMemberWhere, } from './migrations/overlay-index.js'; import { @@ -66,11 +56,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 +111,32 @@ 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, + REFERENCE_VALUE_TYPES, 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 +154,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, @@ -212,7 +195,6 @@ import { REFERENCE_SITES } from './reference-sites.js'; // `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, @@ -240,13 +222,11 @@ import { // [#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 { @@ -425,10 +405,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'; @@ -495,6260 +473,280 @@ 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; +// 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, + 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 { + 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 { + 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, + 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 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": {