diff --git a/.changeset/action-no-placement-lint.md b/.changeset/action-no-placement-lint.md new file mode 100644 index 0000000000..bac8df01c6 --- /dev/null +++ b/.changeset/action-no-placement-lint.md @@ -0,0 +1,45 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +"@objectstack/metadata-protocol": minor +--- + +Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`). + +New advisory rule `action-no-placement`: an action that declares no +`locations` and that no list view places by name renders on **no** surface — +it parses, publishes, and appears in Setup, while no user can ever click it. +ADR-0078 names this shape in its opening paragraph and Phase 3 asks for +exactly this rule; the shared completeness predicate it envisioned was never +built, so this lands standalone, one verified shape at a time. + +What made it verifiable now: objectui#3142 collapsed four disagreeing +renderers onto one placement predicate. Before that, `action:bar` and the +record header rendered an *undeclared* action anyway, so the shape only looked +inert on paper. As of objectui 17.1 it is measurably inert. + +Two things are deliberately **not** flagged: + +- **`locations: []`** — the documented headless action (callable over REST / + MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a + headless declaration is the only legal way to expose one. The rule therefore + distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key + absent) and only reports the latter. +- **Actions a view places by name** — `bulkActions`, `bulkActionDefs` + (including `execution: 'aggregate'` defs, whose whole point is an action with + no single-record home) and `rowActions`, across all three list-view tiers: + `views[i].list`, `views[i].listViews.` and the object-embedded + `objects[i].listViews.`. + +Advisory, never fatal — a view in another installed package may be the one +placing the action, the same reason `validateSemanticRoles` and +`lintLivenessProperties` warn rather than gate. + +Also: the action form schema in `@objectstack/metadata-protocol` no longer +declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()` +tombstones in spec 17, and this schema is what the Studio designer renders its +fallback form from — so advertising them handed authors two inputs that could +only ever produce an unsaveable draft (objectui#3145 removed the matching +dedicated controls). And `content/docs/ui/actions.mdx` now says which surface +is the exception to location filtering, instead of a blanket claim its own +showcase contradicted. diff --git a/.changeset/agent-code-is-the-record.md b/.changeset/agent-code-is-the-record.md new file mode 100644 index 0000000000..1050e17e10 --- /dev/null +++ b/.changeset/agent-code-is-the-record.md @@ -0,0 +1,33 @@ +--- +--- + +docs(spec,metadata-protocol): record why platform `agent` definitions have no metadata change log (#4507) + +Comment-only — no behaviour changes, nothing to release. + +`agent` is the only authorable metadata type with no governed write path +(`allowOrgOverride` and `allowRuntimeCreate` are both `false` per ADR-0063 §2, +which closes `*.agent.ts` to third parties). Its rows are written by the +shipping plugin at boot — `AIStudioPlugin.registerMeta` → `metadataService.register()` +→ `MetadataManager.register` → `DatabaseLoader.save` — which writes +`sys_metadata` directly with a fresh checksum and appends no +`sys_metadata_history` row. So a shipped agent definition that changes between +releases leaves no metadata-side change log. + +That is accepted rather than overlooked, and the reasoning now sits beside the +declaration instead of in an issue: the two definitions live in version control +(`@objectstack/service-ai-studio` in the `cloud` repo), so git already holds the +full reviewable history. A second history in `sys_metadata` would be a *worse* +record — it would capture only the boots where a given deployment happened to +see the checksum move, so two deployments on the same release would carry +different "histories" of an identical, code-fixed definition. + +Two consequences that read as bugs and are not are named explicitly: the +`skipped` outcome `os migrate meta --stored` reports for `agent` rows is correct +and permanent for this type, and Studio showing no History tab for an agent is +the absence of anything to show. `migrateStoredMetadata`'s TSDoc now points at +the note rather than leaving its skip reason to be read as a to-do. + +The note also states its own expiry: if `agent` is ever opened to tenant +authoring, an author-owned definition has no git to fall back on, so opening the +type and giving it a real history path become the same piece of work. diff --git a/.changeset/agents-releases-freeze-merge-queue.md b/.changeset/agents-releases-freeze-merge-queue.md new file mode 100644 index 0000000000..dd5b115156 --- /dev/null +++ b/.changeset/agents-releases-freeze-merge-queue.md @@ -0,0 +1,8 @@ +--- +--- + +Releases nothing — repo process + CI only. `content/docs/releases/` becomes +RELEASE-OWNED (never edited in code PRs; compiled centrally from changesets + +the ADR-0087 registries), AGENTS.md multi-agent §10 scopes the post-merge +re-verify, and the three required-check workflows gain `merge_group:` triggers +so the merge queue can be enabled. No package ships from this change. diff --git a/.changeset/aggregate-bulk-dispatch-selected-ids.md b/.changeset/aggregate-bulk-dispatch-selected-ids.md new file mode 100644 index 0000000000..93ab939ab2 --- /dev/null +++ b/.changeset/aggregate-bulk-dispatch-selected-ids.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): allow the aggregate bulk dispatch key `_selectedIds` through the action param gate (objectui#3139) + +A list view's `bulkActionDefs` entry can now opt into an aggregate single-call +dispatch (`execution: 'aggregate'`, objectui 17.1): the renderer invokes the +named object action ONCE for the whole selection, injecting every selected +record id as `params._selectedIds: string[]`, so a single call can produce one +aggregate artifact (zip of QR codes, merged PDF, batch print job). + +`ACTION_PARAM_BUILTIN_KEYS` gains `'_selectedIds'` so the ADR-0104 strict +param gate does not 400 an aggregate dispatch against an action that declares +params — like `recordId`/`objectName`, the key is dispatcher-injected and can +never be authored as a declared param. Pure widening: actions declaring no +params were never validated, and no authored bag legitimately carried this +key. The `bulkActionDefs` describe now documents the aggregate contract +(server reads `params._selectedIds`, results are all-or-nothing, `batchSize` +does not apply, set `maxRecords` for expensive aggregates, and toolbar +url/api actions can interpolate `${ctx.selection.ids}`). + +The showcase's Task → Bulk Actions view carries the specimen: +`showcase_recalc_selection` dispatches the recalc endpoint once for the whole +selection via the endpoint's new `_selectedIds` batch branch, next to the +per-record fan-out fixtures. diff --git a/.changeset/analytics-record-scoping-and-measure-fields.md b/.changeset/analytics-record-scoping-and-measure-fields.md new file mode 100644 index 0000000000..cf73afa41b --- /dev/null +++ b/.changeset/analytics-record-scoping-and-measure-fields.md @@ -0,0 +1,92 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/service-analytics": minor +--- + +fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437) + +Two defects on the analytics query path, both found by the v17 verification run +(#3909 / #4482), both reproduced against a live showcase server before the fix +and re-verified with the same requests after. + +## #4467 — `/analytics/query` applied no record-level scoping + +`ISecurityService.getReadFilter` documents itself as "the same filter the engine +middleware AND-s into every find", and exists precisely for paths that bypass +that middleware — its own doc comment names the analytics raw-SQL path. But the +chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and +plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s +`buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`). +Only the RLS half was ever computed here, and analytics has no other source of +scope, so the OWD/share predicate simply never existed on that path. + +Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns +5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`. +`GET /data/showcase_private_note` correctly returned 2 for the member, while +`POST /analytics/query {measures:['count']}` returned 5 — and adding +`dimensions:['title']` returned all five titles, i.e. the VALUES of a column +that caller may not read, not merely a bad count. Any authenticated caller who +could reach `/analytics` could enumerate the field values of every row of any +object exposed as a cube, regardless of OWD, sharing rules, or RLS. + +`getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the +late-bound `sharing` service and AND-composes it with the RLS filter — the same +composition the two middlewares reach by both writing into `ast.where`. It also +computes the ADR-0057 D1 `__readScope` depth that the security middleware +normally stashes on the context for plugin-sharing to widen its owner-match +with, using the same `getEffectiveScope` call the middleware makes: no +middleware runs on this path, and without it a caller granted `unit`/`org` read +depth would be silently narrowed to `own`. The sharing predicate is resolved for +every non-system caller AHEAD of the RLS stand-down branches, because those are +the RLS middleware's own early exits and none of them is a reason to drop a +sibling middleware's predicate; a sharing-resolution failure denies outright +rather than falling through to half a scope. + +**Why `minor` rather than `patch`.** This is an observable behaviour change on a +public read surface, in the narrowing direction: analytics results that a +principal could previously read they now cannot. Counts drop, `dimensions` +groupings lose rows, and any dashboard, report, or export built on +`/analytics/query` over an owner-private object will show smaller numbers for +non-superuser principals — correctly, but visibly. Deployments that had (however +unknowingly) come to depend on the unscoped totals will see them change on +upgrade, so this warrants more than a patch-level note even though it is a +security fix. No API signature changed: `ISecurityService.getReadFilter`'s +declaration is untouched — the implementation merely started honouring the +contract it already documented. + +## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR + +`inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has +no way to know the field exists, so it built `SUM(ghost)`, the driver threw +`no such column`, and the caller got +`500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error +class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A +dotted spelling took the same path (`measures:['total.sum']` prefix-strips to +`sum` → `SUM(sum)` → 500). The DATA route has refused the identical mistake with +a `400 INVALID_FIELD` naming the field since #4315/#4254. + +`AnalyticsService.ensureCube` now validates each measure's resolved source field +against the backing object's field names before any SQL is built, and rejects +with the same envelope the data route produces (`400 INVALID_FIELD` carrying +`field`, `object`, `param`, `measure`) so one mistake has one shape across +`/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the +same schema registry `isRegisteredObject` already consults and the data path's +own gate reads, so "which fields exist" has a single answer across both routes. + +The gate is tiered exactly like the #3867 cube-inference gate, deliberately +narrow: it applies only when the cube's `sql` is a bare object name (an authored +cube whose `sql` is a real SQL expression has no field list to check against), +only when the probe answers (no data engine, or an external datasource whose +columns are not mirrored locally, stands down), and only to measures whose +source is a bare column — `count(*)` has no source field, and a dotted +cross-object reference resolves through a join this layer cannot see, so both +pass through untouched. `id`/`created_at`/`updated_at` are admitted +unconditionally, matching the data path's `resolveQueryFields`: a gate stricter +than the engine it guards would reject queries that used to work. Validation +runs before the cube is registered, so a rejected query leaves no trace in the +registry — otherwise a retry would find a "registered" cube carrying the bogus +measure and sail straight into SQL. + +This half is `minor` for the same envelope reason: a request that used to return +500 now returns 400 with a different `code`, which is a visible contract change +for any caller branching on the response. diff --git a/.changeset/approval-decision-survives-restart.md b/.changeset/approval-decision-survives-restart.md new file mode 100644 index 0000000000..a3b834f959 --- /dev/null +++ b/.changeset/approval-decision-survives-restart.md @@ -0,0 +1,64 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +"@objectstack/plugin-approvals": minor +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + +A flow paused at an `approval` node, a deploy, then an approver clicking +Approve: the request row flipped to `approved`, the UI toasted success — and +the flow never moved. No next-stage request, no error, the record's mirrored +status frozen mid-workflow. Approval flows pause for days by design, so a +restart mid-flight is the normal case: every release could quietly zombify +every in-flight approval, with the approvers none the wiser. + +Durable suspended runs (#1518) had shipped and were not the missing piece. Two +other things were. + +**The wiring could enable a store over a table nobody had created.** Object +registration and store activation resolve different services in different +phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin +declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, +warned, and continued; `start()` then attached the DB-backed store anyway. Every +suspend failed with `no such table: sys_automation_run` into a log line nobody +read, pauses silently stayed in memory, and the next restart lost them all. +Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an +engine-less kernel must still boot); a registration missed at `init()` is +retried at `start()`, which still lands before ObjectQL's schema sync; the +store is never attached when registration did not happen, and says so at +**error** level instead of warning; the table is probed once at boot so a +broken setup surfaces there rather than one failed write at a time; and a +failed durable write of a paused run is logged at error — it is data loss in +waiting, not a warning. + +**A reported resume failure read as success.** `AutomationEngine.resume()` +answers a lost run by *returning* `{ success: false }`, never by throwing. +`ApprovalService` discarded that return value, and `decide()` counted only a +thrown error as failure — so a decision against a dead run came back +`resumed: true`, HTTP 200. Resume failures are now classified +(`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining +`PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is +distinguishable from a store that is merely unreachable, and the raw resume +route maps them to 404 / 503 / 409. + +Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which +reads the suspension store, unlike `getRun()`, and throws rather than answering +`false` when the store is unreadable — pre-flights every flow-advancing +operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the +zombie half-state is never created rather than merely reported: the decision +fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A +resume that fails after the decision is durable can no longer be undone, but it +now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of +reporting success. A concurrent duplicate resume stays benign — the engine's +idempotency guard is doing its job — and reports through the new optional +`resumeError` field. Recall and revise-window cancellation stay non-fatal by +design (they abandon the request), but log at error with the reason instead of +swallowing it. Compositions with no automation engine attached are unaffected. + +Existing zombie requests from affected deployments (already `approved`, run +stranded) are not repaired by this change — `releaseDeadRunRequests` only +sweeps requests that are still `pending`. diff --git a/.changeset/approval-override-audit-marker.md b/.changeset/approval-override-audit-marker.md new file mode 100644 index 0000000000..16dd713433 --- /dev/null +++ b/.changeset/approval-override-audit-marker.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": patch +"@objectstack/plugin-approvals": patch +--- + +fix(approvals): record an admin override of a staffed approver slate AS an override (#4466) + +An admin who is not in a request's `pending_approvers` may still act on it — the +`#3424` privileged-override path exists so a request routed to an unstaffed +position, or to approvers who have all left, is not undecidable forever. The +override is defensible; what was not is what the audit trail recorded. + +`sys_approval_action` had no override column at all. So an admin overriding a +properly-staffed slate wrote a row **byte-for-byte identical** to the designated +approver approving normally: a reader of the timeline saw `approve` by the admin +and could not tell whether the admin *was* an approver or *overrode* the ones who +were, and the bypassed approver's later `409 INVALID_STATE` was the only trace — +existing only if they happened to try. The platform knows at decision time (it +took the `isOverrideActor` branch to admit the call at all), so this was dropped +information, not unavailable information. The whole point of an approval record +is to answer "who authorized this, and were they entitled to?". + +`sys_approval_action` now carries **`via_override`** (boolean, optional), set on +exactly the actions admitted by that branch — `decideNode`'s approve/reject and +`reassign`'s admin rescue. It is surfaced on `ApprovalActionRow.via_override` +(`@objectstack/spec/contracts`), returned by `listActions`, and added to the +object's `highlightFields` and two grid list views so a timeline can say +"overrode the approver slate" instead of rendering it as an ordinary approval. + +Three distinctions the column keeps apart deliberately: + +- **`true`** — the actor held no slot in the slate and was admitted only by the + override branch. +- **`false`** — checked, and it was not an override. An admin who *is* a + designated approver is approving normally and records `false`: the marker is + about which branch admitted the call, not about whether the actor holds admin + rights. +- **absent** — a row written before this column existed. "Not recorded" is not + the same claim as "not an override", so `rowFromAction` maps `null` to + `undefined` rather than to `false`. + +Additive and nullable, so this needs no data migration: existing rows keep +working and simply read as unrecorded. Levelled `patch` rather than `minor` +because nothing an author writes changes — but note it *is* an observable +behaviour change on a read surface: `listActions` responses and the +`sys_approval_action` grid views now carry a field consumers did not see before, +and `sys_approval_action` gains a column on next schema sync. diff --git a/.changeset/approvals-stranded-request-inspection.md b/.changeset/approvals-stranded-request-inspection.md new file mode 100644 index 0000000000..53e01ced41 --- /dev/null +++ b/.changeset/approvals-stranded-request-inspection.md @@ -0,0 +1,48 @@ +--- +"@objectstack/plugin-approvals": patch +--- + +fix(approvals): find the zombie requests nothing was looking at (#4469) + +#4460 stopped new zombies being produced; the rows already stuck had no mechanism +to find or release them. The failure shape (#4420) is a request flipped to +`approved` / `rejected` / `returned` whose `flow_run_id` points at a run that no +longer exists — the decision landed, the flow never moved. Any deployment on +17.0.0-rc.1 that hit the wiring hole and crossed a restart mid-approval can be +carrying these rows. + +`releaseDeadRunRequests` could not see them, and the reason is worth stating +plainly: it scans `status: 'pending'`, and the very step that zombifies a request +is the one that takes it OUT of `pending`. The act of breaking it removed it from +the only sweeper's field of view — a large part of why this class of failure +stayed silent. It could not have answered the question even if it had looked: its +liveness oracle is `getRun`, which reads the execution LOG and returns `null` for +a perfectly ALIVE suspended run after a restart. It treats `null` as alive +(conservative, and correct for what it does) — which is exactly why it has no way +to say "this run is really gone". + +Adds `ApprovalService.inspectStrandedRequests()`, which uses BOTH oracles and +reports only rows that fail both: + +- `hasSuspendedRun(runId) === false` — the suspension store itself says no live + pause exists. It THROWS when the store cannot be read, and that case is + SKIPPED and counted as `undetermined`, never condemned: an unreadable store + means "unknown", and a storage outage must not be published as a lost run. +- `getRun(runId) == null` — no terminal history row either. A run that merely + finished is not stranded; a request whose run neither waits nor ever completed + is. + +**It reports; it never rewrites.** No status is changed and no run is cancelled. +The decision genuinely happened — a human approved or rejected — and silently +rolling it back would make the audit trail disagree with the facts. The report +carries what an operator needs to decide: which requests are stuck at which step, +and what the mirrored status field on the business record still reads (usually +the stale value the user is staring at). Whether to re-run the downstream actions +or re-open the approval is a judgement call this cannot make. + +It rides the existing escalation/dead-run sweep clock, so the finding surfaces in +the logs without an operator knowing to go looking for it. `recalled` is +deliberately out of scope: a recall abandons its run on purpose, and reporting +those would bury the real findings under expected ones. + +New export: `StrandedApprovalRequest` (the report row shape). diff --git a/.changeset/audit-anchor-and-lookup-integrity.md b/.changeset/audit-anchor-and-lookup-integrity.md new file mode 100644 index 0000000000..325687ed30 --- /dev/null +++ b/.changeset/audit-anchor-and-lookup-integrity.md @@ -0,0 +1,40 @@ +--- +"@objectstack/objectql": minor +"@objectstack/spec": minor +--- + +fix(data): the audit anchor is engine-owned, and a lookup must resolve (#4447, #4441) + +Two write-path contract holes from the v17 verification sweep. + +**#4447 — `created_at` was client-writable on an ordinary PATCH.** Its two +siblings only looked protected: the audit hook force-advances `updated_at` / +`updated_by` on every update, so a forged value is overwritten. `created_at` is +insert-only, so nothing overwrote it. The root cause is a *declared* audit +field shadowing the platform's: `applySystemFields` skips its injection when +the object already carries the name, and the merge lets the declared one win — +correct for an authored business field, wrong for the audit family. A built app +artifact ships a materialized `created_at` carrying only FieldSchema defaults +(`readonly: false`), which shadowed the engine-owned definition, so the +readonly strip had nothing to key off. The audit family's **governance** +(`readonly` / `system` / `type` / `reference`) is now forced by the platform +while presentation (label, description, hidden, group …) stays the author's. +Back-dating is unaffected: `preserveAudit` (#3479/#3493) and `isSystem` writes +still reinstate the original timeline. The strip now also reports through +`droppedFields`, giving the #3794 contract its first live producer on this axis. + +**#4441 — a `lookup` accepted an id that exists in no row of its target.** +Including `sys_position_permission_set.permission_set_id`, where a dangling row +is a security-surface record that resolves to nothing and the audience-anchor +gate has to resolve that very set to evaluate the grant. Writes are now refused +with `400 VALIDATION_FAILED` and a `fields[]` entry +(`code: 'reference_not_found'`, naming the field, the target and the +unresolvable id) — the catalogued `FieldErrorCode` that had no emitter until +now, with its message in the four platform locales. + +Scope for #4441 is deliberately narrow: caller-supplied keys only (so server +stamps are never reported as the caller's bad reference), non-system writes only +(seed replay and package install keep their ordering freedom), empty means "no +link", and it fails OPEN when the target cannot be checked. The existence probe +is unscoped, because existence is a fact about the database — whether the caller +may create the binding stays the RBAC/RLS layer's decision. diff --git a/.changeset/authoring-rule-command-coverage-registry.md b/.changeset/authoring-rule-command-coverage-registry.md new file mode 100644 index 0000000000..36c8785ee3 --- /dev/null +++ b/.changeset/authoring-rule-command-coverage-registry.md @@ -0,0 +1,68 @@ +--- +"@objectstack/cli": minor +--- + +fix(cli): every author-time rule that can gate runs on all three commands (#4409) + +`os validate`, `os build` and `os lint` each hand-wired their own subset of the +author-time rules. Nothing connected the three lists, so "which rules run here?" +was answerable only by diffing three 800-line files by eye — and the answer +drifted every time a rule landed. The audit found 23 of 26 rules running on some +strict subset, nine of them able to emit `error`. + +The worst direction was the least obvious. `os build` — the command that +PUBLISHES — was the weakest gate of the three: a flow whose expression approver +does not parse (`approval-expression-invalid`) built and published green, and +only `os lint` stopped it, while CI usually runs the other two. `os lint` +disagreed in *both* directions at once, running one gating rule neither other +command ran and missing six that both of them ran, which is worse than no +pre-flight — the remaining options are re-verifying everything or learning to +distrust the signal. + +This is the same failure mode's fifth appearance (#3583, #3782, #4384/#4394, +#4402). Each earlier repair removed an instance and left the MODE: a rule's +command coverage was whatever its author remembered to type, and forgetting was +silent. #4402's guard could not catch the rest — it filtered on the current +member names of one suite, so a rule hand-wired into two commands from outside +that suite passed it without a word. A name list only guards the names on it. + +**The registry.** `AUTHORING_RULES` declares all 26 rules as data: tier +(`gating`/`advisory`), which stack tier they read (pre-parse `normalized` vs +`parsed`), which commands run them, and a written reason for the one narrowing. +All three commands consume it through `runAuthoringRules()`, so adding a rule is +a one-line edit that reaches every command at once. The three command files +shrink by ~1000 lines between them. + +**The ratchet.** The wiring guard is no longer a name list: a `gating` rule on +fewer than three commands fails, a narrowed rule with no reason fails, a command +that calls or imports a registry rule directly fails, and an `advisory` claim is +checked against the rule's own source — so a gate cannot wear an advisory label +to buy itself partial coverage. That last check is the one #3760 needed, having +promoted a `lintFlowPatterns` rule from advisory to gating with nothing anywhere +asking whether its coverage should follow. Remaining direct calls are listed +with reasons, and a stale entry fails too, so the ratchet cannot rot into a +permanent permission slip. + +**The verdict, not just the wiring.** A separate test plants one defect per +previously-blind gating rule and asserts all three commands gate on it, plus the +issue's own repro driven end-to-end through the real CLI: exit 1 on all three +where it was 1/0/0. + +Two behaviour changes fall out of reporting every failing rule in one run +instead of exiting at the first failing gate: an author with three unrelated +problems now sees all three in one pass, and `--strict` covers every advisory +rather than the roughly half that happened to be printed inline. + +Also closes the same hole one gate over: `collectAndLintDocs` failed `os build` +and never ran on `os validate`, invisible because the parity guard keyed on the +`lint*`/`validate*` naming convention and that gate is called `collect*`. The +guard now names each shared non-registry gate explicitly instead of +pattern-matching for them. + +Cost is not what argued against any of this. The heavy dependencies +(`typescript` ~9 MB, `sucrase`) are already lazy and load only when a stack +carries the metadata that needs them, and the heaviest rule of the set has run +on all three commands as a reference-integrity suite member since #4340 without +anyone noticing. The one narrowed rule, `lintUniqueDeclarations`, is scoped +because `os lint` already reports it through `lintDataModel` — coverage +recorded, not coverage missing. diff --git a/.changeset/ci-shard-test-core.md b/.changeset/ci-shard-test-core.md new file mode 100644 index 0000000000..d8288b03ce --- /dev/null +++ b/.changeset/ci-shard-test-core.md @@ -0,0 +1,8 @@ +--- +--- + +CI-only: shard the Test Core job by package (deterministic, test-file-count-balanced +halves via `scripts/partition-test-shards.mjs`), move the dogfood verify-CLI pass into +its own parallel job aggregated by the existing Dogfood Regression Gate, and repoint +the temporal-conformance Turbo cache fallback at the Build Core namespace. Releases +nothing. diff --git a/.changeset/client-readme-retired-validate-only.md b/.changeset/client-readme-retired-validate-only.md new file mode 100644 index 0000000000..467545f344 --- /dev/null +++ b/.changeset/client-readme-retired-validate-only.md @@ -0,0 +1,25 @@ +--- +'@objectstack/client': patch +--- + +docs(client): drop the retired `validateOnly` batch option from the README (#4052) + +The Batch Options section still documented `validateOnly` as a working dry-run — +"validate records without persisting changes" — but the key was retired in #4052 +precisely because nothing ever read it. Every batch surface (`updateManyData` / +`deleteManyData` / `batchData`) persisted regardless, so a caller who sent it to +preview a mutation got that mutation **executed**. + +`BatchOptionsSchema` has carried a `retiredKey(...)` tombstone since #4052, so the +schema already refuses the key loudly. The README was the last place still +promising it — declared-but-not-enforced in prose rather than in code, aimed at +exactly the readers who cannot see the tombstone. + +Released as a patch rather than declared release-nothing because `README.md` is in +this package's `files`: the corrected text only reaches the people who hit the +problem — readers on npmjs.com — if the package ships. + +Replaced with a pointer to `docs/protocol-upgrade-guide.md` +(`batch-options-validate-only-retired`). No behaviour change; there is no batch +dry-run today. Write-path validate-only was evaluated in #4372 and closed as not +planned — no current consumer justifies the surface. diff --git a/.changeset/connector-template-cluster-removed.md b/.changeset/connector-template-cluster-removed.md new file mode 100644 index 0000000000..20f8d8ade0 --- /dev/null +++ b/.changeset/connector-template-cluster-removed.md @@ -0,0 +1,62 @@ +--- +'@objectstack/spec': major +--- + +The per-provider connector "template" cluster is removed (#4480, ADR-0049) + +`@objectstack/spec/integration` no longer exports the six per-provider +connector schemas and their sub-schema/type/example clusters (~110 exports, +2,672 lines): + +- `DatabaseConnectorSchema` (+ `DatabaseProviderSchema`, `DatabasePoolConfigSchema`, + `SslConfigSchema`, `CdcConfigSchema`, `DatabaseTableSchema`, the three + `*ConnectorExample` constants) +- `FileStorageConnectorSchema` (+ bucket/versioning/multipart/filter configs, examples) +- `GitHubConnectorSchema` (+ repository/commit/PR/actions/release/issue configs, examples) +- `MessageQueueConnectorSchema` (+ its queue/topic/consumer configs, examples) +- `SaasConnectorSchema` (+ examples) +- `VercelConnectorSchema` (+ its deployment/domain/env configs, examples) + +The six generated reference pages under `docs/references/integration/` go with +them. + +**Why removal, not completion.** These files were the losing side of an +architecture decision the same module's live half already records. ADR-0023 +rejected hand-modelling each external system's shape inside the spec — +"re-inventing OpenAPI inside this schema" — and ADR-0097's connector protocol +does the opposite: one `ConnectorSchema`, with provider shapes coming from the +provider itself (`connector-openapi` materializes instances from an OpenAPI +document, `connector-mcp` from an MCP server). The templates hardcoded +Postgres/S3/GitHub/RabbitMQ/Vercel shapes into spec files nothing ever read: + +- `engine.registerConnector()` validates against `ConnectorSchema` from + `connector.zod.ts` — never the templates +- the `connectors:` stack collection parses `DeclarativeConnectorEntrySchema` — + never the templates +- nothing else in the monorepo, objectui included, imported any of the six + +They were also semantically wrong where they overlapped the live platform: +`DatabaseConnectorSchema` modelled "tables to sync", CDC, and `readReplicaConfig` +— a second, independent declaration of read-replica routing (the first, +`datasource.readReplicas`, was removed in #4468), complete with a `weight` +field for a load balancer that does not exist. External-database access is +datasource federation (ADR-0015), which is live and is not a connector. + +**Migration.** There is nothing to migrate: these schemas validated no stored +metadata (the `connectors:` collection never used them) and no runtime read +their output. If you imported one as a TypeScript type for your own code, +model your provider config yourself, or — the supported path — declare a +provider-bound connector instance and let connector-openapi / connector-mcp +derive the shape: + +```ts +// before (typed against a dead spec export) +import { DatabaseConnector } from '@objectstack/spec/integration'; + +// after (the live protocol) +import { Connector, DeclarativeConnectorEntry } from '@objectstack/spec/integration'; +``` + +The base protocol — `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the +ADR-0097 provider contract, connector-descriptor, connector auth — is +unchanged. diff --git a/.changeset/datasource-config-driver-contract.md b/.changeset/datasource-config-driver-contract.md new file mode 100644 index 0000000000..9b0d574c7f --- /dev/null +++ b/.changeset/datasource-config-driver-contract.md @@ -0,0 +1,77 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-datasource': minor +--- + +`datasource.config` is now validated against its driver's contract (#4410) + +`config` was the one authorable slot on a datasource with no gate at all. The +schema's own comment claimed "the driver's own `configSchema` is what validates +it" — nothing did: both bundled driver specs set `configSchema: {}`, no code read +the field, and the per-driver zod schemas were not even exported from the +package. So `config: { hostname: 'db.internal' }` (the key is `host`) was +accepted in silence and the datasource connected to `localhost` while the parse, +the save and the connection probe all reported success. + +`DatasourceSchema` now parses `config` against +the contract for the declared driver, and `DatasourceAdminService` +(create/update/test, the Setup wizard's path) applies the same check. Both read +one registry in `@objectstack/spec/data`, which also projects each contract to +JSON Schema for `DriverDefinitionSchema.configSchema` and the Studio connection +form, so the form offers exactly the fields the validator accepts. + +New exports from `@objectstack/spec/data`: `PostgresConfigSchema`, +`MysqlConfigSchema`, `SqliteConfigSchema`, `SqliteWasmConfigSchema`, +`MongoConfigSchema`, `MemoryConfigSchema`, plus `resolveDriverId`, +`getDriverConfigSchema`, `getDriverConfigJsonSchemaById` and +`validateDriverConfig`. A driver the platform ships no contract for (a plugin's +`com.vendor.snowflake`) keeps an unvalidated `config`. + +**Migration.** A config that was silently ignored now fails with the correction +in the message. The renames: + +| Wrote | Write instead | Driver | +| --- | --- | --- | +| `user` | `username` | postgres, mysql, mongo | +| `connectionString` / `dsn` | `url` | postgres, mysql, mongo | +| `uri` | `url` | mongo | +| `file` / `path` / `database` | `filename` | sqlite, sqlite-wasm | +| `hostname` | `host` | postgres, mysql, mongo | +| `searchPath` | `schema` | postgres | + +And the relocations — keys that were never driver config: + +| Wrote in `config` | Write instead | +| --- | --- | +| `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block | +| `schemaMode` | next to `driver`, on the datasource | +| `readOnly` | `external: { allowWrites: false }` — the enforced write gate. (This row said `capabilities: { readOnly: true }` until #4487's liveness audit found that key has no reader.) | +| `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand | + +Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`. +`InMemoryDriverConfig` has no field for either — the driver keeps no indexes and +evicts nothing — so both were inert. Drop them; for real indexing use a driver +that indexes. + +A postgres, mysql or mongo datasource must now name a connection target +(`database`, or a `url` that carries it). An empty `config` used to mean "the +client's own localhost default", which is the same defect in its most complete +form. + +**Also fixed, because the contract can only be enforced where it is honoured.** +These keys were declared and read by nothing; they now reach the driver: + +- `datasource.pool` is honoured by every SQL driver (it was declared, carried + into the connection spec, then overwritten with a hardcoded `{ min: 0, max: 5 }`), + and maps onto the Mongo client's `minPoolSize` / `maxPoolSize`. +- `datasource.schemaMode` reaches the driver. It was dropped between the + datasource record and the connection spec, so a `schemaMode: 'external'` + database — one ObjectStack must never run DDL against — was constructed as + `managed`. +- `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at + the record — nothing put it on the connection spec — so a TLS block configured + nothing, which is exactly what its own schema comment warns about ("a TLS + setting that never took effect looked identical to one that did"). +- postgres `schema` (knex `searchPath`), `applicationName` and `statementTimeout`. +- mongo `password`, `authSource` and `options`. A mongo datasource carrying a + `config.password` previously composed its URL with an **empty** password. diff --git a/.changeset/datasource-mapping-is-routing.md b/.changeset/datasource-mapping-is-routing.md new file mode 100644 index 0000000000..fbb17d71c8 --- /dev/null +++ b/.changeset/datasource-mapping-is-routing.md @@ -0,0 +1,47 @@ +--- +"@objectstack/objectql": minor +"@objectstack/service-datasource": minor +"@objectstack/runtime": minor +--- + +A `datasourceMapping` rule is routing, not a hint — an object mapped to an +unreachable datasource no longer silently reads and writes the DEFAULT store +(#4462). + +**Observable behavior change; read this before upgrading.** Measured on `main` +during the v17 verification: map an object to a Postgres datasource with a bad +URL and the boot succeeds, `/ready` answers `200`, the datasource name appears in +**zero** log lines, `POST /api/v1/data/` returns `201` — and the +row is physically in the default store. The operator finds out by opening the +database they declared and finding it empty. ADR-0062 D2's phase-1 note called a +mapping-only datasource "decorative" to keep an example byte-for-byte unchanged; +what that bought was a silent data-placement bug. + +The fix is a pair, and each half is what makes the other correct: + +1. **Routing stops falling through** (`@objectstack/objectql`). `getDriver` step + 2: a mapping rule that MATCHES and names a datasource with no live driver now + throws — `DatasourceUnavailableError` when the connect layer recorded a + verdict, otherwise an error naming the object, the datasource and the two + remedies. `default` still resolves onward: the default driver keeps its + natural name (#3826), so step 5 is how routing to it works. +2. **ADR-0062 D2 grows gate (d)** (`@objectstack/service-datasource`, + `@objectstack/runtime`). A datasource a mapping rule routes at least one + object to is auto-connected at boot, and a boot-time connect failure is + **fatal** with an operator-readable reason — the same call gate (b) already + makes for an explicit `object.datasource` binding, now correct for (d) + because half 1 removed the fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still + degrades the boot instead, as for every other fatal connect. + +The mapped-object list is resolved by the boot path from the engine's own +matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) and passed to +`connectDeclared({ mappedObjects })`; the connection service never re-derives +rule matching. Two matchers drifting by one clause would connect a datasource +routing never uses, or route to one nothing connects — the defect again. + +**What to do if this breaks your boot.** It means a `datasourceMapping` rule in +your stack points at a datasource that cannot be connected. Either fix the +datasource configuration, or delete the rule — the second is what +`examples/app-crm` did in this change, and it is what keeps that example's +runtime behavior identical: its rules routed everything to an unconnected +`:memory:` datasource, i.e. to the default store by fall-through. diff --git a/.changeset/datasource-read-replicas-removed.md b/.changeset/datasource-read-replicas-removed.md new file mode 100644 index 0000000000..fa75e64a7b --- /dev/null +++ b/.changeset/datasource-read-replicas-removed.md @@ -0,0 +1,49 @@ +--- +'@objectstack/spec': major +--- + +`datasource.readReplicas` is removed (#4468, ADR-0049 enforce-or-remove) + +It described replica connections nothing ever opened. `ConnectableDatasource` +and `DatasourceConnectionSpec` carry no replicas field, the driver factory never +reads the key, and no query path distinguishes a read from a write — the +platform has no read/write splitting at all, so every statement always went to +the primary no matter what was declared here. + +**Migration.** + +| Wrote | Write instead | +| --- | --- | +| `readReplicas: [{ host: 'replica-a', … }]` | delete the key | +| `replicas: [ … ]` (the alias) | delete the key | + +There is no target to move to, because there is no read-replica routing to move +to. If you need replica reads today, front them behind a single endpoint — +pgpool, ProxySQL, an RDS reader endpoint — and point `config` at that endpoint. +That is the one read-scaling path that works, and it worked before this key was +removed too. + +Run `os migrate meta --from 16` to strip it from your sources; the +`datasource-read-replicas-removed` conversion emits one notice per datasource. +Authoring it now fails the parse with the same prescription. + +**Why this one is worth reading about.** #4410 closed the `datasource.config` +gap and, in passing, extended the new per-driver validation over each +`readReplicas` entry — reasonably, since replicas carry the same shape. The +result was a slot that had every marker of a working feature: declared with a +doc comment, `.strict()`-guarded against typos at the top level, and +field-by-field validated against the driver's contract underneath. A replica +block with a misspelt `hostname` was rejected by index, naming the canonical +key. + +None of that is evidence of a consumer, and all of it reads like one. That is +the specific trap ADR-0049 exists for: rigor is cheap to add to a dead slot and +expensive to distinguish from life. Two independent surfaces had drawn the +wrong conclusion — this validation, and objectui's datasource preview, which +rendered a "2 read replicas" pill confirming the config to the author while +nothing routed a single read. The preview goes with the key (objectui side, +same change); `packages/spec/liveness/README.md` has the standing rule it +violated ("an authoring/preview renderer is NOT a runtime consumer"). + +Read-replica routing remains unbuilt. It is tracked as a feature request rather +than left as a schema key that looks like one. diff --git a/.changeset/decision-branch-routing-enforced.md b/.changeset/decision-branch-routing-enforced.md new file mode 100644 index 0000000000..a546d1638d --- /dev/null +++ b/.changeset/decision-branch-routing-enforced.md @@ -0,0 +1,101 @@ +--- +"@objectstack/service-automation": minor +"@objectstack/spec": minor +"@objectstack/cli": minor +"@objectstack/example-crm": patch +--- + +fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) + +A `decision` node advertised three mechanisms for splitting a path and only one +of them did anything. The other two were the ADR-0049 `declared ≠ enforced` +shape, and the pair of them shipped a guard that does not guard in +`examples/app-crm`. + +| mechanism | before | now | +|:---|:---|:---| +| `edge.condition` | ✅ the only one that worked | unchanged | +| `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | +| `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | + +## What was broken, end to end + +`crm_convert_lead_wizard` means "already converted → abort screen; otherwise → +the wizard". It ran **both**: an already-converted lead got +"This lead has already been converted" and then walked straight into the +conversion wizard behind it. Four independent silences stacked up: + +1. the decision's first condition was authored `{lead_record.status} == + 'converted'` — braces in a slot declared bare CEL, so it was string-compared + and never true; +2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; +3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal + discarded the branch and considered every out-edge; +4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking + it `isDefault: true`, was a dead key. + +## The model + +`branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` +catches whatever is left. Concretely: + +- **`isDefault` is enforced.** A default edge is traversed only when no + conditional sibling of the same source node matched, and it is no longer part + of the unconditional parallel fan-out — that distinction is the whole point of + the marker. Passed over because a real branch won, its target records the same + `skipped` step a closed gate does (#4354). +- **An unclaimable branch label warns.** Traversal still falls back to the full + edge set (a run mid-flight must not die on a metadata error) but says so, + naming the computed branch and the out-edge labels that exist. +- **A decision that declares no `conditions` reports no branch.** It used to + report `'default'` unconditionally — a label no out-edge in the repo ever + carried — which is why every decision node fell back to the full edge set. + The `'default'` sentinel survives for the case it actually describes (declared + conditions, none matched) and is now claimed by the `isDefault` edge as well + as by an edge literally labelled `'default'`. +- **`conditions[].expression` is evaluated as the bare CEL it is declared to + be.** The raw string went to the legacy `{var}` template path, where + `lead.status == 'converted'` cannot resolve and the branch is decided by + string comparison. Unlike `edge.condition` this slot carries no + `ExpressionInput` envelope — the decision descriptor is deliberately + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate + now fails loudly (ADR-0032 §1c) instead of deciding `false`. + +## Caught at authoring time too + +Four new `os build` / `os validate` warnings, because a wrong route is silent at +run time by nature (Prime Directive #12): + +`flow-branch-label-unmatched` (the shipped shape), +`flow-decision-unconditional-branch` (a guarded decision with an unconditional +sibling — the actual hole), `flow-default-edge-with-condition` and +`flow-multiple-default-edges`. + +Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent +after it. + +## Effect on flows that already exist + +Enforcing `isDefault` changes how a **stored** flow behaves, and the flows it +changes are mostly Studio's own. `objectui`'s flow edge inspector has always +written `isDefault: true` when you bind an out-edge to a decision's default/else +branch — into a key with zero readers, so that edge ran unconditionally, in +parallel with whichever branch actually matched. Those flows now take exactly +one branch. That is the fix, but it is a behaviour change on existing data +rather than only on newly authored metadata, so it is worth knowing before +upgrading: a flow that quietly ran two paths will now run one. + +Nothing changes for an edge that never carried the marker — `isDefault` defaults +to `false`, and an ordinary unconditional out-edge still fans out in parallel +exactly as before. + +## The example app + +`crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the +redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One +mechanism per decision, and exactly one branch runs. + +Verified: 11 new engine/executor tests (including the reported repro in both +directions), 12 new linter tests; `@objectstack/service-automation` 577 tests +and `@objectstack/cli` 652 tests green, all three example apps build with no new +findings. diff --git a/.changeset/discovery-cache-queue-job-no-route.md b/.changeset/discovery-cache-queue-job-no-route.md new file mode 100644 index 0000000000..70990a5b4e --- /dev/null +++ b/.changeset/discovery-cache-queue-job-no-route.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +"@objectstack/runtime": patch +--- + +fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318) + +The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue` +and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no +dispatcher domain, no adapter mount, no plugin registration, and the shipped +providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will +never mount one. Every default boot therefore advertised a route inside the same +`ServiceInfo` whose `handlerReady: false` said the opposite — a single record +contradicting itself (ADR-0076 D12). + +These slots are route-less now, like `realtime` — but unlike `realtime` an +unmarked real implementation stays `available`: the slot's contract is +in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady` +is reported `false` on both discovery builders — for a route-less slot it is not +a proxy for anything, it is the fact itself (the dispatcher used to claim +`handlerReady: true` here for an unmarked occupant, a handler that does not +exist). The explanatory message is written once, as +`inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two +builders cannot drift apart. diff --git a/.changeset/dual-source-export-ratchet.md b/.changeset/dual-source-export-ratchet.md new file mode 100644 index 0000000000..8b9363c9c9 --- /dev/null +++ b/.changeset/dual-source-export-ratchet.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +--- + +feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, caught by symbol identity (#4446) + +`api-surface.json` records every export per entry point, so a name appearing on +two entries was VISIBLE — but nothing distinguished a re-export (one +declaration, two import paths — fine) from a **dual-source** (each entry +resolving the shared name to its OWN declaration, so which type a consumer gets +depends on nothing but the import path). The dual-source case is the #4411 +trap: spec carried two differently-shaped `MetadataWatchEvent`s plus ten more +pairs, and the copy that *looked* canonical was the dead one — an auto-import +or model completion picking by name compiled fine and failed later, at an edge +value. + +New pure check `check:dual-source-exports` (lint.yml, after the build step): + +- **Judged by symbol identity, not name.** Every export of all 16 public + entries is resolved through its alias chain to the original symbol; a name + whose entries resolve to ≥2 distinct symbols is dual-source. Name-based + counting would drown the signal — the real surface carries 148 legitimate + re-exported names. +- **Shrink-only baseline** (`dual-source-exports.baseline.json`): the 63 + existing dual-source names are recorded (including the `MetadataFormat` + `./shared`≠`./system` enum divergence, the `./contracts` third-shape + interfaces, and two type-vs-const cases `ShareRecipientType` / + `TransformType`). A NEW dual-source fails the gate with the fix at the + declaration (converge + re-export, or rename); a resolved one fails until its + line is deleted. The baseline is hand-edited under review, deliberately not + generated — a `gen:` would admit new dual-sources via "run the fix command". +- **Self-tests first** (like `check:exported-any`): a fixture proves the + detector still flags a true dual-source (incl. type-vs-const) and still + passes re-exports, so a resolution failure can never read as "clean". + +No runtime code changes; no export changes. The 63 baseline entries are +pre-existing debt, now visible and non-growing. diff --git a/.changeset/duplicate-package-flow-canonicalization.md b/.changeset/duplicate-package-flow-canonicalization.md new file mode 100644 index 0000000000..486ef32708 --- /dev/null +++ b/.changeset/duplicate-package-flow-canonicalization.md @@ -0,0 +1,61 @@ +--- +"@objectstack/metadata-protocol": patch +"@objectstack/cli": patch +--- + +fix(metadata-protocol): `duplicatePackage` stops minting pre-protocol flow rows (#4498) + +`duplicatePackage` canonicalizes each source row before re-saving it, under a +stated guarantee: "duplication never mints new rows in a pre-protocol dialect." +It delivered that through `convertStoredItem`, which opens with +`if (singular === 'flow') return { item: data, notices: [] }` — so for flows the +guarantee was **not** delivered. + +It did not fail loudly either. `FlowNodeSchema.config` is an open `z.record`, so +a pre-17 body (a `delete_record` carrying `config.filters`) sails through +`saveMetaItem`'s schema gate and lands verbatim in a brand-new row. + +**Why this mattered more than an un-migrated row.** ADR-0087 justifies the whole +stored-metadata design on new writes always being canonical, *therefore* the +stored pass being "a strictly shrinking concern". `duplicatePackage` was a live +producer contradicting that for flows: an operator could run +`os migrate meta --stored --apply`, get a clean report, duplicate a package, and +be back to having pre-protocol rows — with the report still saying protocol N +until the next run. + +**The capability was already reachable.** The reason for the flow skip is real — +flow-node conversions carry ADR-0078's open-namespace conflict guard, which needs +the automation engine's live executor registry to tell a rename from a clobber. +But the protocol is constructed with an accessor for the kernel's service table +(the same one `analytics` and `package` are read from), and the automation +service registers under `automation`. A new private `resolveFlowCanonicalizer` +reads `canonicalizeStoredFlow` (#4454) off it, so every caller running next to a +live engine gets flow coverage without threading anything. + +- **`duplicatePackage`** canonicalizes flow rows through it. A refused rename + fails that item into the existing `failed[]` naming the token — copying the + un-renamed body would mint exactly the row this fixes. A flow that cannot + canonicalize fails the same way. With no engine reachable (a control-plane or + metadata-only host) the source body is copied as-is: no worse than the source + row already is, and failing an unrelated duplication over it would be its own + regression. +- **`migrateStoredMetadata`'s `canonicalizeFlow` becomes an override.** It now + defaults to the resolver. The CLI stopped passing one — it boots its inert + engine into the same kernel, so both routes reached the same instance, and two + routes to one capability is how they drift. The parameter stays for callers + with no registry and for testing the flow branch without an engine. +- **Resolution is lazy, per call.** Plugin init order does not guarantee + `automation` is in the table when the protocol is assembled (the CLI adds it + after ObjectQL by design), so caching `undefined` from a too-early read would + disable flow canonicalization for the life of the process. + +Two smaller honesty fixes ride along: a source item that fails *conversion* (a +tombstoned key throws) is now reported as such instead of as `unparseable +metadata`, and `migrateStoredMetadata`'s "no engine" skip reason says no +automation service is reachable rather than blaming the caller for not supplying +one. + +Reads are unchanged. `getMetaItems` / `getMetaItem` / `getMetaItemLayered` / +`loadMetaFromDb` still skip flows — they are reads, covered by `registerFlow` +canonicalizing at execution, and are not producing bad data. Duplication was the +one that writes. diff --git a/.changeset/findone-requires-a-predicate.md b/.changeset/findone-requires-a-predicate.md new file mode 100644 index 0000000000..826c8f8500 --- /dev/null +++ b/.changeset/findone-requires-a-predicate.md @@ -0,0 +1,70 @@ +--- +"@objectstack/objectql": major +"@objectstack/spec": patch +"@objectstack/driver-mongodb": patch +"@objectstack/driver-sql": patch +--- + +fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419) + +`findOne` reads a single row, which makes its predicate the only thing between +the caller and *an arbitrary record*. When the predicate is missing the result is +not `null` — it is the object's **first row**: a real, plausible-looking record +with nothing to do with the request, which the `if (!row)` check every call site +already has cannot catch, and which then propagates into whatever is computed +next. Reported downstream: line items defaulting their price from the first +product in the catalog rather than the selected one, and "is this deal already +closed?" answered against an unrelated record while the write that followed +correctly targeted the intended id. A throw would have been caught in +development; a `null` would have been caught by the null-check. A valid-looking +wrong record defeats both. + +**Breaking — `findOne` now refuses a query that selects nothing in particular.** + +FROM → TO: + +| Was | Now write | Meaning | +|---|---|---| +| `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | +| | `findOne(o, { search: 'Acme' })` | the record this search finds | +| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | +| | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site | + +One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest +one", or switch to `find(o, { limit: 1 })` if any row will do. The error names +all four. `find` and `count` are unchanged — returning or counting every row is +an honest answer; only `findOne`'s implicit "just one of them" turns a missing +predicate into a confidently wrong record. The guard reads the CALLER's +predicate, before RLS/sharing middleware injects its own: a tenant filter +narrows which rows are visible, it does not make "whichever comes first" +something the caller asked for. + +**Two silent drops that produced the same wrong record are fixed with it.** + +- **`findOne({ search })` applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion lived inline in `find` and nowhere else, + while `find` and `findOne` are checked against the SAME legal-key set — so + `search` passed the gate, rode onto the AST, and reached a driver. No driver + reads `ast.search`. The read therefore ran with no predicate at all and + `limit: 1` did the rest. The expansion is now one method both call. +- **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It + translated `query.where` and dropped the rest, so `findOne({ orderBy })` did + not return the newest record — it returned whichever document the scan reached + first. `find` and `_findStream` in the same driver had always handled all + three. This one matters beyond Mongo: the guard above tells an unpredicated + caller to reach for `orderBy`, and an escape hatch one backend ignores is not + an escape hatch. No ordering is IMPOSED when the caller supplies none — both + drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo + "never sorting" is corrected, since it cited the dropped parameter as + agreement. + +**And a gate so the class does not come back.** A drift pin walks +`ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an +observable effect — on the AST the driver receives, on the driver options, or in +an explicit "not executed, and here is why" entry (only `limit`, which the +contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through +two rounds of hardening because nothing asked that question. + +Together with #4346 (`filter` → `where` folds on every entry point) and #4400 +(unknown option keys throw), a read parameter the engine does not execute now +fails at the call site instead of quietly changing the answer. diff --git a/.changeset/flow-branch-gates-and-inert-condition.md b/.changeset/flow-branch-gates-and-inert-condition.md new file mode 100644 index 0000000000..eb89aa503e --- /dev/null +++ b/.changeset/flow-branch-gates-and-inert-condition.md @@ -0,0 +1,61 @@ +--- +"@objectstack/cli": minor +"@objectstack/example-showcase": patch +"@objectstack/example-todo": patch +--- + +fix(cli): gate the two decision-routing shapes that can never work, and flag the inert `config.condition` (#4414) + +Two follow-ups to #4440, both about metadata that reads like a guard and is not +one. + +## Two rules promoted to `error` + +`flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the +build instead of warning. The bar for that — restated at the top of +`lint-flow-patterns.ts`, because the old one no longer described the set — is +**no reading of the author's metadata does what it says, deterministically, on +every run**. Both qualify: a branch label no out-edge carries cannot route, and +an edge that is both `isDefault` and conditional always lets the condition win, +so the marker routes nothing. Neither *fails*; both are wrong every time and +silently, which is worse. + +The other two stay advisory on purpose, and the policy now says why: +`flow-decision-unconditional-branch` is usually a guard that does not guard, but +one guarded plus one unconditional out-edge is also a legal "maybe notify, +always continue" fan-out, and `flow-multiple-default-edges` can genuinely mean +"when nothing matched, do both". The bar is about *provability*, not severity of +consequence — failing a customer's build on a shape we cannot prove wrong is the +worse trade. + +No wiring change was needed: `lintFlowPatterns` is already registered as +`tier: 'gating'` across all three commands (#4409), which is exactly the seam +`authoring-rule-wiring.test.ts` exists to guard. + +## New rule: `flow-inert-node-condition` + +`config.condition` is the trigger gate on a `start` node and is read by **no +other node type** — the engine parse-validates it everywhere (so a malformed one +is caught) and then ignores it. On a `decision` the name makes it read as the +branch predicate, which is exactly how it got authored. + +Three of the three bundled apps had one. `app-todo`'s `check_recurring` and +`app-showcase`'s `needs_exec` both carried a predicate their out-edges were +already enforcing — a third copy doing nothing. The showcase even had a comment +next to it saying the node condition "is not evaluated by the engine", and kept +it anyway; that is the residue this rule exists to stop accumulating. Both are +now plain exclusive gateways. + +Advisory, not gating: the surrounding edges usually still route correctly, so +this is dead weight rather than a provable misroute. The node-type list is a +closed set of builtins we have actually read, not "everything that isn't +`start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately +declare and read its own `config.condition`. + +## Studio + +`objectstack-ai/objectui` carries the matching help-text fixes: the branch editor +said a `true` branch **is** the default/else path (it is how you *ask* for one — +the marker goes on the out-edge), and the legacy single `Condition` field said +"prefer Branches above", which reads as "this works, but the other is better". +It does not work at all. diff --git a/.changeset/flow-condition-bare-string-is-cel.md b/.changeset/flow-condition-bare-string-is-cel.md new file mode 100644 index 0000000000..392cd46119 --- /dev/null +++ b/.changeset/flow-condition-bare-string-is-cel.md @@ -0,0 +1,53 @@ +--- +"@objectstack/service-automation": minor +--- + +fix(automation): `evaluateCondition` decides the dialect from the source, not from the caller (#4336) + +`AutomationEngine.evaluateCondition` picked its engine by asking whether an +`{ dialect, source }` **envelope** was present. A condition handed to it as a +plain string therefore never reached the CEL engine: it fell through to the +legacy `{var}` template path, which substitutes brace holes and then compares +whatever text is left — **as text**. Nothing errored, and the run was recorded +as `success`, with the failure direction depending on the predicate: + +| Handed in | Actually evaluated | Result | +|:---|:---|:---| +| `existingTask == null` | `'existingTask' === 'null'` | always **false** — gate never opens | +| `record.rating >= 4` | `'record.rating' >= '4'` → `'r' > '4'` | always **true** — branch pinned open | + +#4414 fixed the one built-in that was reaching this — the `decision` executor +now wraps `conditions[].expression` in a CEL envelope before calling. This +fixes the **evaluator**, so the next caller does not have to remember: the +dialect is now read from the source, and a condition is CEL unless it actually +contains a `{var}` hole. `evaluateCondition` is public API, so a +plugin-registered node executor evaluating its own predicate was getting the +table above with nothing to warn it. + +**The legacy `{var}` dialect keeps working** where it always did — +`{amount} > 100`, `{status} == active`, `{a.b} == 7` — and gains the two things +it was missing: + +- **A quoted literal compares as its contents.** `{status} == 'active'` used to + compare `active` against `'active'` — quotes included — and was false for + every value of `status`. It is the spelling the flow docs showed, and quoting + a string literal is what every other predicate surface requires. +- **It no longer answers `false` when it could not resolve something.** A `{…}` + hole matching no flow variable (`{lead_record.status}` — `get_record` stores + the whole row under one name, so that key never exists) and a substituted + value that is neither a boolean, a number, nor part of a comparison are + refused with the source and the offending reference attached. Both used to be + a silent `false`, which ADR-0032 §1c forbids: a predicate that cannot be + evaluated is a fault, never a quiet branch decision. + +Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap +and still throw — stating the dialect is the author saying "this is CEL". The +sniff reads the source outside string literals, so `record.label == '{pending}'` +stays CEL and compares the field. + +**Tightening to know about:** a bare string that is not valid CEL now raises +where it previously string-compared to some answer. That includes the +host-language payloads the safety tests use (`process.exit(1)`, +`require("fs")…`) — nothing executed before and nothing executes now, since CEL +has no `process`, no `require` and no arrow functions, but the failure is a +reported fault instead of a silent `false`. diff --git a/.changeset/form-layout-lint-wired.md b/.changeset/form-layout-lint-wired.md new file mode 100644 index 0000000000..5bcb972a16 --- /dev/null +++ b/.changeset/form-layout-lint-wired.md @@ -0,0 +1,25 @@ +--- +"@objectstack/cli": minor +--- + +Wire `validateFormLayout` into the authoring-rule registry, and close the +registry from the other direction (#4449). + +`validateFormLayout` was implemented, unit-tested, exported from +`@objectstack/lint` and given published rule ids (`form-field-unknown`, +`absolute-colspan-discouraged`) — and **no command ever called it**. It ran on +zero stacks for as long as it existed, so a form section referencing a field +that is not on the bound object, or pinning an absolute `colSpan` under a +per-surface derived column count, produced no output anywhere. It is now an +`advisory` entry in `AUTHORING_RULES`, so `os validate`, `os build` and +`os lint` all run it. It is a pure structured-metadata walk with no lazy +dependency, so all three commands pay nothing measurable. + +The wiring guard (#4409) could not have found this. Every one of its invariants +starts FROM a registry and looks at the commands, which is blind by construction +to a rule that never entered a registry — the same shape as #4402's name list +guarding only the names on it, one layer up. The guard now also runs the reverse +subtraction: every `validate*` / `lint*` symbol on `@objectstack/lint`'s public +barrel, minus `AUTHORING_RULES` ∪ `REFERENCE_INTEGRITY_RULES`, must be empty or +carry a written reason in `UNWIRED_RULE_LEDGER`. The ledger ships empty: today's +difference was exactly this one rule. diff --git a/.changeset/govern-remaining-nine-metadata-types.md b/.changeset/govern-remaining-nine-metadata-types.md new file mode 100644 index 0000000000..38781d6d17 --- /dev/null +++ b/.changeset/govern-remaining-nine-metadata-types.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': patch +--- + +Liveness coverage is complete: the nine remaining registered metadata types are +governed (#4488) — `app`, `book`, `doc`, `email_template`, `job`, `mapping`, +`seed`, `translation`, `validation` — and `PENDING_GOVERNANCE` is empty. Every +type in the metadata-type registry now has a ledger with per-property verdicts, +evidence, and a `verifiedAt` stamp. + +Spec: + +- Nine new ledgers under `packages/spec/liveness/` (≈150 verdicts). Highlights: + the ENTIRE `email_template` authoring surface is dead (nothing materializes + metadata items into the `sys_email_template` rows `sendTemplate` reads — an + admin editing the password-reset mail in Studio changes nothing; #4509); + `app.areas[].visible` / `areas[].requiredPermissions` are fail-open dead + gates (item-level siblings ARE enforced); `translation.validationMessages` + is read by nothing while #3778's own migration table steers authors into it; + `job`/`validation` have runtime-authoring doors disconnected from their + execution points (#4509). `doc` and `seed` are fully live. +- `check-liveness.mts`: the walker now sees through `z.preprocess` pipes + (takes the OUT side when the IN side is a transform) — `translation`'s + registered schema was unwalkable before this. +- `liveness/README.md`: the per-type count table's method is now decided and + recorded (it mirrors `check-liveness.mts --json` `byStatus`, the number CI + enforces); all rows regenerated from one run, and the two-generations-stale + `webhook` row rewritten to the post-#3489/#3494 state. + +CLI: + +- `lint-liveness-properties` registers the six newly governed types that carry + `authorWarn` entries (`apps`, `books`, `jobs`, `emailTemplates`, `mappings`, + `translations`), so authors hear about the misleading keys at compile time. diff --git a/.changeset/kernel-metadata-loader-envelope-removed.md b/.changeset/kernel-metadata-loader-envelope-removed.md new file mode 100644 index 0000000000..3c8c1095c5 --- /dev/null +++ b/.changeset/kernel-metadata-loader-envelope-removed.md @@ -0,0 +1,78 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: remove the `kernel` metadata-loader envelope family — eleven names that each existed twice, with different shapes, on two subpath entries (#4411) + +`MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, +`MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, +`MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo` and +`MetadataLoaderContract` (plus each one's `…Schema`) are removed from +`@objectstack/spec/kernel` (`kernel/metadata-loader.zod`). Every one of those +names *also* existed, with a **different shape**, in +`@objectstack/spec/system` (`system/metadata-persistence.zod`). + +Which type you got depended on nothing but your import path: + +```ts +import type { MetadataWatchEvent } from '@objectstack/spec/kernel'; // one shape +import type { MetadataWatchEvent } from '@objectstack/spec/system'; // another +``` + +- **The `kernel` copies had zero consumers.** Import-statement scans across this + repo, `cloud` and `objectui` found every consumer importing from + `./system` (or, for the export/import options, `./contracts`' own interface). + Nothing but `kernel/metadata-loader.test.ts` ever parsed the `kernel` copies. +- **The naming intuition pointed the wrong way**, which is what made this worse + than an ordinary duplicate. The `kernel` copies were the ones that *looked* + canonical — normalized enums, required fields, a `.describe()` on every + property — and they were the dead ones. The live copy is the loose superset, + and `metadata-manager.ts` calls it "legacy" in its own comments. An + auto-import or a model completion picking by name, or by which one reads as + more rigorous, picked the dead one; because the shapes overlap heavily, that + choice compiled and only failed later, at an edge value (`add` vs `added`) or + on a field one copy made required. +- **No load path parsed them.** These are runtime envelope types, not authorable + metadata — no authored source can carry them. So there is deliberately **no** + `retiredKey()` tombstone and **no** ADR-0087 conversion: a prescription nobody + can receive is noise, and there is nothing for `os migrate meta` to rewrite + (the `plugin-runtime.zod.ts` / dev-plugin precedents, #3950, #4149). + +**FROM → TO — change the import path, keep the name:** + +```diff +-import type { MetadataWatchEvent, MetadataStats } from '@objectstack/spec/kernel'; ++import type { MetadataWatchEvent, MetadataStats } from '@objectstack/spec/system'; +``` + +The surviving `system` copy is the **looser** of the two, so a *reader* of these +types may need narrowing it did not need before; a *producer* needs nothing. The +differences that actually bite: + +| Type | `kernel` (removed) | `system` (keep) | +| --- | --- | --- | +| `MetadataWatchEvent.type` | `'added' \| 'changed' \| 'deleted'` | also `'add' \| 'change' \| 'unlink'` — the raw watcher values the runtime really emits | +| `MetadataWatchEvent` | `metadataType` / `name` / `timestamp` required | all three optional; adds `stats` | +| `MetadataStats` | `size` / `modifiedAt` / `etag` / `format` required | all optional; adds `mtime`, `hash` | +| `MetadataFormat` | `json \| yaml \| typescript \| javascript` | also the `yml` / `ts` / `js` aliases | +| `MetadataSaveResult.path` | required | optional; adds `stats` | +| `MetadataImportOptions` | `conflictResolution` / `dryRun` / `continueOnError` / `transform` | `source` / `strategy` / `validate` | +| `MetadataCollectionInfo` | `formats: MetadataFormat[]` | `namespaces: string[]` | + +No runtime behaviour changes: nothing read the removed copies. The `system` +shapes are **not** tightened here — they describe what `MetadataManager` +actually emits, and narrowing them would be a separate behaviour change. + +`MetadataManagerConfig` and `MetadataFallbackStrategy` are **unaffected**. They +were never duplicated — `kernel` owns them and `system` re-exports them — and +that is the split that survives: manager *wiring* is kernel's, the loader/watch +*envelope* is system's, and nothing is declared twice. + +The retirement kit: baselines dropped deliberately +(`json-schema.manifest.json` minus the 11 `kernel/Metadata*` entries; +`authorable-surface.json` minus the 65 matching lines — nothing can author +these, so no `[RETIRED]` markers); `api-surface.json` regenerated (22 exports +leave `./kernel`); `references/kernel/metadata-persistence.mdx` removed by +`gen:docs`; v17 release notes' dead-clusters table and upgrade checklist +extended. No liveness-ledger entries existed (the ledger tracks authorable +metadata types; these were never one). diff --git a/.changeset/liveness-governs-every-registered-type.md b/.changeset/liveness-governs-every-registered-type.md new file mode 100644 index 0000000000..bbc29915aa --- /dev/null +++ b/.changeset/liveness-governs-every-registered-type.md @@ -0,0 +1,71 @@ +--- +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +The liveness gate now governs every registered metadata type (#4487) + +`GOVERNED` in `check-liveness.mts` was a hand-maintained list, and nothing ever +compared it against the registry it claims to cover. It governed **15 of 25** +registered metadata types while reporting itself complete. A type in the other +ten was authorable — served by `/api/v1/meta/types/:type`, editable in Studio — +and was never asked who reads its properties, so an inert key on it was +invisible to CI and its silence read as success. + +`datasource` was in that state for its entire life. #4410, #4465 and #4481 found +six inert keys on it **by hand**, two of them security-shaped: `schemaMode` was +dropped between the record and the connection spec, so a database ObjectStack +must never run DDL against was constructed as `managed`; `ssl` stopped at the +record, so a TLS block with a CA certificate in it configured nothing while +looking identical to one that worked. + +**The gate is now answerable to the registry.** Every registered type must be in +`GOVERNED` or in `PENDING_GOVERNANCE` with a reason and an issue. Registering a +type and forgetting the ledger fails CI with the entry to write. The reverse rots +too, so it also fails: a `PENDING_GOVERNANCE` row for a type that has since been +governed claims a debt that no longer exists. + +**`datasource` is now governed** — `liveness/datasource.json`, all 43 properties +classified with evidence. The result is the highest dead ratio of any governed +type: **20 of 43 have no runtime consumer.** + +| Dead cluster | Why | +| --- | --- | +| `capabilities.*` (11) | The engine gates pushdown on the runtime driver's own `supports.*` object — `autonumber`, `batchSchemaSync`, `queryDateGranularity` — a different mechanism whose vocabulary does not overlap this block at all. `having-filter.ts` says it outright: "SQL pushdown can come later behind a driver capability flag." | +| `healthCheck.*` (3) | Nothing schedules a datasource probe. Liveness is checked on demand through the driver handle's `ping()`. | +| `retryPolicy.*` (4) | No connect or query path retries. | +| `external.label`, `external.requirePermission` | No reader. | + +**One correction ships with this**, and it is the reason the audit was worth +doing rather than a bookkeeping exercise. `capabilities.readOnly` reads as a +safety switch and gates nothing — and **two shipped prescriptions pointed +authors at it**: the `externalSettingsUnknownKeyError` guidance in +`datasource.zod.ts` ("or `capabilities.readOnly` to describe the driver") and +the #4465 changeset's relocation table. Both now name `external.allowWrites: +false`, which is the write gate the ObjectQL engine actually checks. An author +who followed the old advice believed they had marked a datasource non-writable +and had not. The v17 release notes carried a matching false claim — that an +unregistered `capabilities` key made the engine stop pushing work down to the +driver — corrected in the same change. + +Two traps worth naming, because both nearly produced a wrong verdict here: + +- **`healthCheck` and `retryPolicy` are name collisions.** A bare grep for + either returns plenty of live readers — the plugin health monitor, `hook`, + `job` — none of which is this type. `hook.retryPolicy` even spells its delay + `backoffMs` where this declares `baseDelayMs`; the shape mismatch is the tell + that nothing reads both. +- **objectui's `DatasourcePreview` renders `pool`, `ssl`, `retryPolicy` and + `healthCheck` as panels**, and is cited as evidence for none of them. That is + the standing rule in `liveness/README.md`, and #4481 is the fresh precedent: + the only "consumer" of `readReplicas` in either repo was a preview pill. + +The CLI advisory lint picks the ledger up automatically, so `os compile` now +warns an author who sets any of the 20. That needed one line beyond the ledger — +`datasource` had to be added to `TYPE_COLLECTIONS`. Coverage grows by marking +entries `authorWarn` only *within* a type the lint already walks; a newly +governed type needs its collection registered or its ledger warns nobody. + +Nine types remain ungoverned and are now enumerated rather than implied: +`app`, `book`, `doc`, `email_template`, `job`, `mapping`, `seed`, `translation`, +`validation` (#4488). diff --git a/.changeset/meta-canonical-type-segment.md b/.changeset/meta-canonical-type-segment.md new file mode 100644 index 0000000000..b01b33fe2b --- /dev/null +++ b/.changeset/meta-canonical-type-segment.md @@ -0,0 +1,27 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +One canonical type key at the `/meta` read/write/delete boundary (#4432). + +#3985 made the per-type gates accept both spellings of the `/meta` type segment +(`/meta/actions` and `/meta/action`). It did not FOLD them, so the two spellings +addressed two different namespaces and the layers below disagreed about which +one an item lived in. `saveMetaItem`, `getMetaItem`, `getMetaItems`, +`getMetaItemLayered`, `getMetaItemCached` and `deleteMetaItem` now fold the type +to its canonical singular (Prime Directive #3) as their first act, so every layer +below them reads one key. + +The damaging consequence was not the duplicate row — it was the shadowing. +`getMetaItems` hydrated overlay rows back into the SchemaRegistry under the +CALLER's spelling, so one plural-spelled read minted a plural registry entry; +from the next read on, `listItems('actions')` was no longer empty, the singular +fallback that had been supplying every code-authored action stopped running, and +a single overlay row hid the entire code-authored listing — on a spelling no +DELETE could address, because the delete path resolved the singular. Listing and +dispatch then disagreed about an item that had been deleted. + +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. What changed is that nothing WRITES or REGISTERS a non-canonical key any +more. diff --git a/.changeset/meta-migrate-stored-route.md b/.changeset/meta-migrate-stored-route.md new file mode 100644 index 0000000000..f46edb7492 --- /dev/null +++ b/.changeset/meta-migrate-stored-route.md @@ -0,0 +1,47 @@ +--- +"@objectstack/rest": minor +"@objectstack/runtime": minor +"@objectstack/client": minor +--- + +feat(rest,runtime,client): `POST /meta/_migrate-stored` — run the stored-metadata migration without a shell (#4327) + +`os migrate meta --stored` (#4327) gave ADR-0087's stored-metadata chain a finish +line, but only for someone who can reach the deployment's database from a +terminal. A hosted operator cannot, so on a managed deployment the chain had no +finish line at all — just the per-read conversion, running forever, with no way +to assert what protocol the rows are on. + +The same pass is now reachable over HTTP: + +```ts +const preview = await client.meta.migrateStored(); // writes nothing +const result = await client.meta.migrateStored({ apply: true }); +const flows = await client.meta.migrateStored({ types: ['flow'] }); +``` + +It returns the same `StoredMigrationReport` the CLI renders, and takes the same +posture: + +- **Preview by default.** `apply` must be literally `true`; an empty body, a + missing body, and `"apply": "yes"` all preview. Nothing is inferred. +- **Gated on `manage_metadata`.** Unlike the single-item `PUT /meta/:type/:name` + next door, this rewrites every eligible row in the deployment, so it demands + the ADR-0066 D1 authoring capability rather than just a session, and answers + `403` otherwise. The gate runs before the protocol is probed, so an + unauthorized caller cannot use `403`-vs-`501` to learn which kernels can be + migrated. `/meta`'s anonymous-deny umbrella still closes it to anonymous + callers first. +- **Attributed to the caller.** The `actor` recorded on the history and audit + rows names the user who fired it — that is the question those rows exist to + answer. + +**Flows need no extra setup on this path.** The CLI has to boot an inert +automation engine to hold the executor registry ADR-0078's conflict guard needs; +a server already has a live one, and the protocol resolves it from the services +registry itself (#4498), so this route covers flow rows by simply running in the +process that owns them. + +Registered on both the REST server and the runtime dispatcher's `/meta` domain, +ledgered in both route ledgers, and mounted before `/:type` so the +leading-underscore segment is never captured as a metadata type name. diff --git a/.changeset/migrate-meta-stored-rewrite.md b/.changeset/migrate-meta-stored-rewrite.md new file mode 100644 index 0000000000..3e90b8ec56 --- /dev/null +++ b/.changeset/migrate-meta-stored-rewrite.md @@ -0,0 +1,62 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/cli": patch +--- + +feat(migrate,metadata-protocol): `os migrate meta --stored` rewrites sys_metadata rows so the read-path chain has a finish line (#4327) + +#4317 closed the correctness gap from the read side: every stored-row +rehydration seam replays the full ADR-0087 conversion chain, retired entries +included, so a row written under any past protocol is *served* canonical +forever. What it deliberately did not do is make the rows themselves canonical. +A pre-17 row keeps its legacy bytes, the chain re-lowers it on every load, and +each affected row logs one conversion notice per process — deduped, but back +every boot. Until now the only things that ever rewrote such a row were a Studio +re-save and `duplicatePackage`. + +**`os migrate meta --stored`** is the pass that ends it for a deployment that +runs it. It walks `sys_metadata` — `active` and `draft`, every organization — +replays the same `applyConversionsToStoredItem` chain, and re-saves each changed +body through the normal write path, so a rewritten row gets a +`sys_metadata_history` entry, a fresh checksum and the mutation projectors, +exactly like an author's save. The history row's `source` is `migrate-stored`, +so a later diff distinguishes an upgrade from somebody's edit. + +```bash +os migrate meta --stored # preview: per-row report, writes nothing +os migrate meta --stored --apply # rewrite the rows (prompts) +os migrate meta --stored --apply --yes --json # CI / scripts +os migrate meta --stored --type view # restrict to a type (repeatable) +``` + +**Preview is the default and `--apply` is the only writing mode** — the house +rule its siblings already keep (#3617's "a dry run changes nothing"), and it +applies with more force here because what moves is metadata: every affected +row's checksum and a history entry per row. An apply run also refuses to start +while another process holds the SQLite database, for the same reason +`os migrate files-to-references --apply` does. + +**Nothing gates on this having run.** #3855's conclusion stands — an +operator-run migration cannot be relied upon, so the read path remains the +guarantee for every deployment, and no `sys_migration` flag is recorded (a flag +would advertise enforcement that does not exist). What a run buys is hygiene — +rows stop carrying pre-protocol dialects, so diffs, exports and history are +clean going forward, and the recurring notices go quiet — plus one thing that +was previously unobtainable: **an operator can assert it.** A run with nothing +left to do exits `0`, a deployment with rows still on an old dialect exits `1`, +so "my metadata is on protocol N" becomes a CI check rather than a belief. + +Three things the pass declines, and reports rather than counting as done: +`flow` rows (their seam is `AutomationEngine.registerFlow`, which holds the +executor registry the node-type conflict guard needs), types with no repository +write path (`agent` — rewriting there would record no history and force a draft +live), and rows that still fail the current schema after conversion (a genuine +contract violation the write path is right to refuse; it keeps reading through +the chain and stays fixable in Studio). + +Also new, and usable without the CLI: `protocol.migrateStoredMetadata()` returns +the same structured report an admin route would render, and `saveMetaItem` +accepts an optional `source` for the history/audit rows. `source` is not +request-derived — the REST layer builds its save request field by field and +never forwards a client-supplied value, so provenance stays something the server +states rather than something a caller claims. diff --git a/.changeset/protection-envelope-invariant-was-hollow.md b/.changeset/protection-envelope-invariant-was-hollow.md new file mode 100644 index 0000000000..8209a5cf9c --- /dev/null +++ b/.changeset/protection-envelope-invariant-was-hollow.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +The protection-envelope invariant test was hollow — it silently skipped 24 of 25 registered types. Fixed, and it immediately found 8 undeclared envelopes instead of 1. + +The check shipped in the previous change asserted two things about every registered metadata type: that it does not *reject* the ADR-0010 envelope its loader stamps (the hard-422 case), and that it does not *strip* it (the silent-loss case). The reject half worked — it found `hook` and `datasource` on its first run. + +The strip half did not. It probed each schema with one generic body and asked whether `_packageId` survived; a type whose required fields that body did not satisfy failed for unrelated reasons and the assertion returned early. **24 of the 25 types took that early return.** Only `field` was ever actually checked, and the suite reported green. + +That is the campaign's own subject matter — a success signal covering an omission — reproduced inside the instrument built to detect it, one change after the ledger recorded the same lesson about the strictness gate's non-recursive directory walk. A check that skips is indistinguishable from a check that passes. + +**The declaration side is now structural.** It walks the schema — unwrapping `lazy` / `pipe` / `optional` / `default` and expanding unions — and asks whether any resolved object shape declares the key. That answer does not require constructing a valid instance, so it cannot skip. Two guards keep it honest: a type whose shape the walker cannot resolve is a hard failure (the walker going quiet is exactly when this test would otherwise stop covering something), and the debt list carries a reverse pin that fails when an entry is fixed, so the list cannot outlive the debt it tracks. + +**What it found:** 8 registered types do not declare the envelope, not 1 — `action`, `book`, `field`, `job`, `mapping`, `page`, `translation`, `validation`. `job` and `book` are closed here, leaving 6 on the list. Each is protection metadata lost on every round-trip today, and a hard 422 the day its schema is closed. diff --git a/.changeset/react-blocks-declaration-parity-not-conformance.md b/.changeset/react-blocks-declaration-parity-not-conformance.md new file mode 100644 index 0000000000..b1a9d00708 --- /dev/null +++ b/.changeset/react-blocks-declaration-parity-not-conformance.md @@ -0,0 +1,50 @@ +--- +'@objectstack/spec': patch +--- + +`check:react-conformance` → `check:react-declaration-parity` — the gate compares two declarations, and said it compared a declaration to an implementation. + +Its header opened by claiming it "confirms the objectui components **ACTUALLY implement** +the props the spec protocol declares". It never could. Both sides of its diff are +declarations: the spec zod schema's props on the left, and on the right the `inputs` the +objectui *registry config* declares — copied verbatim into `sdui.manifest.json` by +`manifestFromConfigs`. No renderer appears anywhere in the chain. So a prop **both sides +declare and nothing reads** is, to this gate, perfect agreement. + +That is not hypothetical. #4413's four blocks (`record:details` / `record:highlights` / +`record:related_list` / `record:path`) published `objectName`/`recordId` that no renderer +read, rendered a "bind a record to preview" placeholder on a `kind:'react'` page, and sat +behind `{ "frontendOnly": [], "missing": false }` in the committed baseline for the whole +life of the defect. A human reading the objectui renderers found it. A gate reporting +green on a promise it cannot keep is worse than no gate — without one, someone checks by +hand. + +Prime Directive #10 (declared ≠ enforced), landing on the thing whose job is to catch it. +Same shape as #1475's "spec declares 9 validation rules, the executor honors 3". + +- **Renamed to what it does**, name and header together, because the name was load-bearing + in the misreading: `check-react-blocks-declaration-parity.ts`, + `react-declaration-parity.baseline.json`, and `frontendOnly` → `registryOnly` in the + baseline ("the registry *declared* it", not "the frontend *implements* it"). +- **The scope caveat is emitted on every run, clean ones included.** Whoever forms a + belief about this gate is reading a CI log, not a source header. +- **It actually gates now.** `gen-sdui-manifest.sh` ran it without `--strict` and swallowed + the exit code behind a `⚠`, so even the divergence it *could* see was recorded and never + stopped (#4472 secondary finding 1). The ratchet fires only on divergence new since the + accepted baseline, so a failure is always a deliberate registry change. +- **The claim is pinned by a test.** `check-react-blocks-declaration-parity.test.ts` + asserts both directions of what the gate can see, that the caveat rides along, and that + the implementation claim does not come back. + +What it sees is unchanged and still worth having — `spec-only` (palette gap, soft), +`registry-only` (undocumented extension, ratcheted), `missing` (not registered / not +public). Exactly one class is invisible: both sides declare it, nothing reads it. + +Evidence about the render path has to come from the render path, which is objectui's side. +`public-block-binding-reach.test.tsx` there mounts every public block declaring an +`objectName` under a recording `dataSource` and asserts the binding reaches it; its first +run separated five bound blocks from three unbound and surfaced two real defects of the +same shape (objectui#3144) — the confirmation this evidence was never obtainable here. +ADR-0082 carries the addendum; the 2026-06 audit carries a correction banner over the +assumption that carried the mistake ("the component reads its full config from the spec +schema at render" — an expectation, never measured). diff --git a/.changeset/react-tier-record-blocks-withdrawn.md b/.changeset/react-tier-record-blocks-withdrawn.md new file mode 100644 index 0000000000..1eb06fd48d --- /dev/null +++ b/.changeset/react-tier-record-blocks-withdrawn.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413) + +The react-tier contract published `objectName` / `recordId` on +``, ``, `` and +``, and no renderer read either prop. All ten `record:*` renderers +take their record from `useRecordContext()`, which only the record route +(`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever +mount; the `kind:'react'` page renderer wraps the page in a +`SchemaRendererProvider` alone. So the blocks rendered their "bind a record to +preview" placeholder — or, for `record:related_list` (the one that does read +`schema.objectName`), refused to fetch because the parent id never arrived. A +page authored exactly to contract came back EMPTY with nothing reported +anywhere, including by `os validate`, which resolved those props' field names +against the object they named: lint standing guard over a binding that never +ran. + +Withdrawn rather than implemented. The contract was not merely unimplemented, +it was the wrong SHAPE: per-block bindings describe four independent fetches of +one record, which is exactly the coupling the shared record context exists to +prevent (`record:details` drops the fields a mounted `record:highlights` +registered; one inline-edit save bar commits them all under a single +`ifMatch`). Honoring the props would have fossilized that (Prime Directive +#12). The naming of that primitive — a record SCOPE an author wraps around the +family, one fetch, shared context — is the open design question, filed as #4444. + +`@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the +ledger for why, plus the working replacement per type. The family is derived +from `ComponentPropsMap`, so a record component added later is gated the day it +lands — including the six that were never in the contract but are just as +reachable through the registry-built react scope. + +`@objectstack/lint` gains `react-block-needs-record-context` (error), which +rejects them on a react page by tag and through `` +alike, quoting the block that does work: `', '=', +parentId]}>` for a related list, `` for a +field panel. A locally-declared component of the same name shadows the injected +scope and is left alone. diff --git a/.changeset/reference-id-embedded-record.md b/.changeset/reference-id-embedded-record.md new file mode 100644 index 0000000000..7512c8bd44 --- /dev/null +++ b/.changeset/reference-id-embedded-record.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": patch +--- + +A stored reference value that is an embedded record is no longer a valid id +(#4455). + +`os migrate value-shapes` is the evidence half of the ADR-0104 D1 per-deployment +gate, and its own header names the case it exists for: "a `location` stored as +`{latitude, longitude}` **or a `lookup` holding an expanded record object**". The +second case was not detected. `ReferenceIdValueSchema` was +`z.string().min(1)`, and in a SQL deployment a legacy embedded reference reaches +storage as JSON *text* in a TEXT column — a non-empty string. So a deployment +carrying exactly the values the gate exists to find ran the scan, was told it was +clean, and closed the gate with `--apply`; because the scan deliberately imports +the write-path predicate, the write path was equally blind and the value survived +future writes too. + +`ReferenceIdValueSchema` now rejects a value whose first non-space character is +`{` or `[`, in both the stored and the expanded form (`$expand` produces an +object, never its serialization). + +The rejection is deliberately narrower than the issue's first suggestion. Its +file sibling `FileReferenceIdValueSchema` can bound its charset because a +`sys_file` id is minted by the platform and by nothing else; a reference id is +whatever the target object's primary key holds, including an external key an +ADR-0015 federated datasource supplies. So this rejects the shape that is +provably not an id (`{"id":"acc_1","name":"embedded"}`) and leaves the id +alphabet to the object that owns it — `CB0-2026-0001`, `SFDC:001xx…` and +`ops/eu-west/tenant-7` all remain valid. Widening it further needs evidence about +real external keys, not a guess. + +Reaches authors through the ADR-0104 warn-first path (a `[value-shape]` log line) +until a deployment opts into strict, so nothing starts rejecting writes on +upgrade — but the scan now counts these values, and a deployment holding them can +no longer close the gate. diff --git a/.changeset/schemaless-node-expression-ledger.md b/.changeset/schemaless-node-expression-ledger.md new file mode 100644 index 0000000000..5d3d070181 --- /dev/null +++ b/.changeset/schemaless-node-expression-ledger.md @@ -0,0 +1,76 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": patch +"@objectstack/lint": patch +--- + +fix(spec): a node that publishes no descriptor configSchema can now own an expression-ledger entry (#4439) + +`FLOW_NODE_EXPRESSION_PATHS` is the #4027 ledger that tells `registerFlow` and +`objectstack validate` which config keys hold expressions, and in which dialect. +Its ratchet (`config-expression-ledger.test.ts`) derives what it expects from +descriptor `configSchema` `xExpression` markers, and fails in **both** +directions — an undeclared marker, or a ledger entry nothing declares. + +`decision` / `script` / `subflow` publish **no** descriptor `configSchema` on +purpose: a published partial schema would drop the editors their hand-written +Studio forms need (the #4210 incident), so their contract lives in +`schemaless-node-config.zod.ts`. Those two rules compose into a hole — an +expression slot on a schemaless node is structurally unreachable by the ratchet, +and because the reverse direction rejects unclaimed entries, it cannot be +entered by hand either. + +`decision.conditions[].expression` sat in that hole. Its own schema says +*"Bare CEL predicate deciding this branch"* and its own comment names `{…}` as +the #1491 trap, and no validator walked it — so `{lead_record.status} == +'converted'` passed `tsc`, passed `objectstack validate`, passed registration. +#4414 made that fail loudly at run time; this makes it fail at build time, +which is the delay #4027 exists to remove. + +## The fix + +The ratchet now reads **both** declaration channels: + +- **descriptor `configSchema`** — unchanged, enumerated from the live registry; +- **`schemaless-node-config.zod.ts`** — the marker rides + `.meta({ xExpression })` through `z.toJSONSchema`, the same channel + `loop.collection` has used since objectui#2670. + +Spec hands the second channel over as JSON Schema +(`getSchemalessNodeConfigJsonSchemas()`, memoized, `input` mode — the shape a +descriptor's `configSchema` already is), so the ratchet walks both with the +*same* function. No second notion of "a declared expression property", which is +the duplication a ledger exists to remove, and no `zod` dependency added to +`service-automation`. Each channel is separately asserted non-empty, so a broken +derivation on one side cannot hide behind the other's results. + +`SCHEMALESS_NODE_CONFIG_SCHEMAS` is also exported for anything else that needs +to reason about all node config contracts. Additive — objectui's +`flow-node-config` reconciliation imports each schema by name and is unaffected. + +## The sweep + +The other schemaless slots were checked and deliberately carry no marker: +`script.template` is a template **id**, not a body; `script.inputs` / +`script.variables` / `subflow.input` are values that interpolate `{token}` — +text-with-holes, the shape essentially every node config string has, already +covered generically by `validate-flow-template-paths` and the CLI flow linter. +A `flow-template` ledger entry means something narrower: a *reference that must +resolve to a value*, like `loop.collection`. So `decision.conditions[] +.expression` is the only genuinely declared expression slot on the class — now +recorded in the ledger's header so it is not re-derived. + +## Docs corrected + +The flows guide taught the **wrong dialect** for decision predicates in three +places (`'{order_amount} > 10000'`), plus a "braces missing in a decision +expression" warning that inverted after #4414 — and `FlowNodeSchema`'s own +`@example` did the same. All corrected to bare CEL, with the history stated so +an author with a braced predicate knows what changed and why their build now +fails. The dialect table drops from three dialects to two: predicates never take +braces, values always do. + +Verified: 13 new/updated tests across the ratchet, the engine's registration +pass and `@objectstack/lint` (including the exact app-crm predicate rejected at +both `registerFlow` and `objectstack validate`); `pnpm build`, `pnpm typecheck` +(122 tasks), `pnpm lint` and `check:docs` clean. diff --git a/.changeset/screen-resume-declared-field-contract.md b/.changeset/screen-resume-declared-field-contract.md new file mode 100644 index 0000000000..78b441e368 --- /dev/null +++ b/.changeset/screen-resume-declared-field-contract.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": patch +"@objectstack/service-automation": patch +"@objectstack/runtime": patch +--- + +fix(automation): `resume` enforces the suspended screen's declared field contract (#4477) + +A `screen` node's `config.fields` is a complete input contract — the author +declares the keys, their `required`-ness, and (via `visibleWhen`) when a field +is even asked for. The RENDER half honoured all of it: the paused result and +`GET …/runs/:runId/screen` carry `required` and `visibleWhen` intact. There was +no VALIDATION half — `POST …/runs/:runId/resume` folded whatever bag it was +handed straight into the flow variables, so a caller that skipped the dialog and +posted here directly was unconstrained by every `required` the author wrote. +Missing required fields, and keys the screen never declared, all completed the +run with `success: true`. + +Screen flows are the one place where the declared field contract is the ONLY +contract — no object schema sits behind a screen node to catch a bad bag +downstream. The platform already enforces the analogous contract everywhere else +this seam appears: action params (ADR-0104 D2), record writes (ADR-0113), +approval `decisionOutputs` (#3447). This is that rule for screen resume, built in +the same shape. + +`resume` now refuses a non-conforming submission with the new +`AutomationResult.code` `'INVALID_SCREEN_INPUT'` (a transport maps it to **400**, +as the automation domain route now does) and an `Invalid screen input: …` message +that names each violation and lists the declared field names. The refusal happens +BEFORE the suspension is consumed, so the pause stays live and the legitimate +submission still lands. + +`visibleWhen` is evaluated against the SUBMITTED values first (layered over the +run's variable snapshot), so a hidden field's `required` never fires — enforcing +it would dead-end the run at a field the user was never shown, which is #3528 +reproduced server-side. A predicate that cannot be evaluated is logged and +treated as hidden rather than visible: the client decides what the user saw, and +a broken predicate is not evidence a field was on screen. + +Scope, deliberately narrow — three shapes keep the historical pass-through: + +- an **object-form** screen (`kind: 'object-form'`), whose `fields` is empty by + construction because the client renders the object's own form and the write + path enforces that object's `required` fields itself; +- a **message-only** screen (`waitForInput: true`, no fields), which declares no + keys and so constrains none — the same pass-through `enforceActionParams` + gives a param-less action; +- `signal.output`, the node-OUTPUT namespace, which belongs to the approval-style + resume envelope rather than to the screen's collected-values channel. diff --git a/.changeset/script-branch-keys-retired.md b/.changeset/script-branch-keys-retired.md new file mode 100644 index 0000000000..d1830f5d1f --- /dev/null +++ b/.changeset/script-branch-keys-retired.md @@ -0,0 +1,86 @@ +--- +'@objectstack/spec': major +'@objectstack/service-automation': minor +'@objectstack/lint': patch +--- + +feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343) + +A `script` node had four ways to name what it ran and only one of them ran anything. +Protocol 17 keeps that one and retires the rest. + +- **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a + line, reported success, and delivered nothing — under any configuration, installed + messaging service or not. Every bundled example used one; none of them ever sent + anything. +- **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed + a message no channel sent. (The examples did not even reach them: they passed the + payload in `inputs`, which the built-in branch never read.) +- **inline `config.script`** was recognized and **never executed** — the built-in runtime + has no server-side JS sandbox, so the node warned and completed as a no-op. +- **any other `actionType`** was shorthand for a registered-function name — a second + spelling of `config.function` — and `'invoke_function'` was a marker that named nothing + on its own. + +What remains is what worked: `config.function` (now **required**) names a registered +function, `config.inputs` feeds it, `config.outputVariable` binds its return value. + +**The replacements are three different mechanisms, not one rename.** + +| Retired | Use instead | +| --- | --- | +| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | +| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel | +| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | +| `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` | + +**Execute-time parse.** `script` and `subflow` now run their config through the contract +before executing, the seam #4277 gave the flat builtins — a violation refuses the node as +a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join +that seam while its legal key set depended on `actionType`: a flat parse would either +reject valid shapes or wave everything through. Converging the node is what made the +contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its +message is now `subflow 'n1': config does not satisfy the subflow contract — +config.flowName: …`. `decision` deliberately stays export-only: its one key is optional, +so a parse would check nothing. + +**Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these +keys in TypeScript is a compile error carrying the same prescription. A shorthand +`actionType` **converts into `function`** — that is what it named — unless `function` is +already set, in which case it was dead metadata the executor never reached. The other four +keys are dropped outright: nothing read them, so there is no value to preserve, and +rebuilding the intent is an authoring decision (the table above) rather than something a +mechanical rewrite can guess. + +The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired +for *misdescribing themselves* rather than for being renamed: absorbing +`actionType: 'email'` silently would let an author keep believing the flow sends mail. The +one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) — +a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node +arrives stripped of the keys nothing read and then **refuses for naming no callable**, +where it used to log a line and report success. That flip is the behavior change to expect. + +**A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a +**lowered handler ref** (a non-empty string), the form `objectstack build` produces: the +CLI lowers every inline callable to a serialisable ref *before* the stack is parsed (it +must — `z.function()` wraps callables and would break the ref mapping), so a built +manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was +that `defineStack({ functions })` — a documented, first-class mechanism — could not +survive a build at all. Nothing had noticed because no bundled example used it; #4343 +turns that from latent into blocking, since `config.function` becomes the only thing a +`script` node can run. `Hook.handler` already declared exactly this pair (`z.union([ +z.string(), ])`, "string, post-build / inline function, pre-build"), so this +brings `functions` onto the platform's established shape rather than inventing one. A +string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the +real functions ride in the sibling ESM module the build emits, merged by name — so +hand-authoring one registers nothing and fails loudly at execute ("no function named '…' +is registered"), never silently. + +Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`, +`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed +(they described the dispatch set that no longer exists); `os validate` names a retired key +and its replacement instead of reporting a generic missing callable; and the `#3796` +alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer +describes an end state protocol 17 can reach — the rename itself is untouched. No liveness +ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is +`z.record(z.unknown())`, so these keys were never governed by one. diff --git a/.changeset/sharing-rule-withdrawal-and-delete.md b/.changeset/sharing-rule-withdrawal-and-delete.md new file mode 100644 index 0000000000..1eb87c64da --- /dev/null +++ b/.changeset/sharing-rule-withdrawal-and-delete.md @@ -0,0 +1,55 @@ +--- +"@objectstack/plugin-sharing": minor +--- + +fix(plugin-sharing): deactivating or deleting a sharing rule actually withdraws its grants (#4433, #4434) + +An over-granting sharing rule had no withdrawal path on the product's API +surface. Deactivating it left every grant it had materialised in place — not on +the next record touch, not after a full restart — and the DELETE route answered +500 for both address forms it advertises, so the rule could not be removed +either. Together that made a too-broad rule unrecoverable short of hand-editing +`sys_record_share`, against a v17 release note that advertises the opposite +("switching a rule off actually withdraws access"). + +`minor`, not `patch`: this changes an observable runtime behaviour that +deployments may have adapted to. A `source: 'rule'` grant whose rule is +inactive — or whose rule row is gone — now disappears, on the deactivating +write, on the next touch of the record, and on the next boot. Anything relying +on those rows surviving deactivation (including data repaired by hand around +the old behaviour) will see them revoked on upgrade. `DELETE +/api/v1/sharing/rules/:idOrName` also starts succeeding where it used to 500, +so callers that treated that 500 as "unsupported" will now really delete. + +#4433 — three independent gaps, one per path the report walked: + +- **The deactivating write.** The `sys_sharing_rule` reconcile trigger skipped + every `isSystem` write, on the theory that those were boot seeding. + `SharingRuleService.defineRule` — the only implementation behind + `POST /sharing/rules`, and the documented way to deactivate a rule — writes + with SYSTEM_CTX unconditionally, because it must reach a platform table the + sharing middleware otherwise gates. So the skip caught 100% of REST + authoring: the withdrawal path built by #3821 existed, had tests (against a + mocked session the real path never sends), and was unreachable in production. + Now gated on boot phase, which is the question the skip actually meant to + ask. +- **The record touch.** `evaluateAllForRecord` listed only active rules, so a + deactivated rule was absent from the loop entirely and its grants were never + examined. It now reconciles every rule; an inactive one desires nothing and + takes the existing revoke-the-remainder branch. +- **The boot pass.** `backfillRuleGrants` was handed an `activeOnly` list, + making it structurally incapable of revoking anything. It now walks every + rule, and a new `sweepOrphanedRuleGrants` retires grants whose rule row is + gone entirely — unreachable by rule iteration, so they need their own sweep. + +#4434 — `deleteRule` purged `sys_record_share` with a predicate-shaped +`engine.delete` carrying neither a scalar id nor `multi: true`, the one shape +the engine's dispatch refuses; it threw before ever reaching the rule row. +Fixed by routing through the same `SharingService.revoke` path every other +withdrawal already uses, rather than adding `multi: true` — a rule's grants now +retire exactly one way instead of two divergent ones. + +The unit fakes are part of the fix: `makeEngine().delete` accepted any `where`, +which is why #4434 shipped green — the pre-existing "deleteRule drops rule and +all its grants" test asserted success against a delete the running server +always rejected. The fakes now mirror the real engine's dispatch guard. diff --git a/.changeset/stored-migration-covers-flows.md b/.changeset/stored-migration-covers-flows.md new file mode 100644 index 0000000000..f4f8d07d42 --- /dev/null +++ b/.changeset/stored-migration-covers-flows.md @@ -0,0 +1,67 @@ +--- +"@objectstack/service-automation": minor +"@objectstack/metadata-protocol": minor +"@objectstack/cli": patch +--- + +feat(automation,migrate): `os migrate meta --stored` now covers flow rows too (#4454) + +#4327 gave the stored-metadata conversion chain a finish line for every +metadata type except `flow` — the one type where the most stored dialect +actually lives, since the graduated conversions `flow-node-crud-filter-alias`, +`flow-node-crud-object-alias`, `flow-node-notify-config-aliases` and +`flow-node-script-config-aliases` are all flow-node entries. Flow-node +conversions carry ADR-0078's open-namespace conflict guard, which has to consult +the *live* executor registry to tell a rename from a clobber, and the metadata +layer has no way to obtain one. Flows were reported `skipped` with that reason. +They are now converted. + +**One canonicalization policy, two shapes.** +`AutomationEngine.canonicalizeStoredFlow` is the single implementation and +`registerFlow` calls it, so the load seam and the migration can never disagree +about what "canonical" means. It returns `parsed` (for execution — the +`FlowSchema.parse` + #4347 region output, schema defaults materialized) and +`storable` (for persistence). + +**`storable` excludes schema defaults, and that is the load-bearing decision.** +Measured rather than assumed: driving a pre-17 flow through all three steps +*removes* nothing — `FlowSchema` is strict since #4001, so an unrecognized key +throws instead of being silently dropped, which means the +`graftNormalizedOperators` precedent (it exists because the *view* parse strips +Studio-only auxiliary keys) does not transfer — and *adds* only defaults: +`version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the +author never wrote would pin every migrated row to today's value while untouched +rows follow tomorrow's: two populations with different behaviour, which is +exactly the drift this pass exists to remove. So the write-back is the +conversion result plus the `{dialect, source}` envelopes the schema derives for +edge conditions, and nothing else. + +One subtlety worth knowing if you extend this: that envelope is a schema +transform, not a conversion, so it emits **no** notice while still changing the +body. Reading notices alone — correct for every other metadata type — would call +such a row canonical and leave it re-deriving on every boot. Both passes are +copy-on-write, so identity is the exact test for flows. + +**New: `AutomationServicePluginOptions.armRuntime`** (default `true`, so every +server, dev stack and test host is unaffected). Set `false` and the plugin +brings up the engine and the complete node registry — built-ins plus whatever +`automation:ready` contributes, because a *partial* registry would make the +conflict guard read a live custom node type as unowned and rewrite over it — and +then stops before anything is armed: + +| Skipped when `armRuntime: false` | Why it must be | +|---|---| +| flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live | +| declarative connector materialization | opens real connections; an MCP provider spawns a child process | +| suspended-run wait-timer re-arm | would resume someone's paused approval mid-migration | + +`os migrate meta --stored` boots the plugin in that mode. A migration process +must not become a second server. + +A refused rename — the guard firing because the old node-type token is a live +name something else owns in this environment — fails that row loudly, naming the +token and its owner. Never a silent skip, never a clobber. A flow that cannot +canonicalize at all (a strict-schema violation, a malformed control-flow region) +is reported as failed with the parse message rather than persisted as a guess; +such a row cannot register today either, so the report is telling you about a +flow that is already broken at runtime. diff --git a/.changeset/strict-object-registered-types.md b/.changeset/strict-object-registered-types.md new file mode 100644 index 0000000000..cc6afc9e84 --- /dev/null +++ b/.changeset/strict-object-registered-types.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +`strictObject` makes closing an authoring shape one call; `seed` and `doc` are the first two registered metadata types converted with it; and a new invariant test found two live protection-envelope bugs on `hook` and `datasource`. + +**The helper.** The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]` array, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. That was 34 key arrays and 16 probe files with most of the authorable surface still ahead — and the array was never necessary: `knownKeys` feeds only the edit-distance suggestion, and the shape object is right there at the call site. `strictObject({ surface, history, aliases?, guidance? }, shape)` derives it, which also removes the per-schema drift probe: a key list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay **optional** — they carry judgement rather than transcription, and treating curation as a precondition is part of why the ratchet moved slowly. + +**A sharper targeting rule.** The five-directory triage answers "is this authorable?" but not "is this parsed?" — and after #4410 that second question decides whether a flip enforces anything. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness at all; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things. + +**Two live bugs, found by a check rather than by reading.** `MetadataPlugin`'s artifact loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc`). A new invariant test over the registered-type registry found it twice more on its first run: **`hook` and `datasource` had both gone strict in the #4001 data step without the envelope.** Both now declare it. The test asserts the hard case (rejects) unconditionally with no exemption list, and tracks the quieter case (silently strips, currently only `field`) separately. + +**Ledger.** `strictObject` replaces the old wiring recipe as the standard, and the gate's site-counting method now counts `strictObject(` alongside `z.object(` — counting only the latter would have made every conversion look like surface disappearing, so "solved" and "deleted" would read the same. The gate caught that itself on the first conversion. + +Authoring impact: on `seed` and `doc`, a key the schema never declared is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names the surface, echoes the key and suggests the closest declared one (`rows` → `records`, `body` → `content`), with tombstones for `path` / `slug` on `doc`. The published JSON Schema is unchanged: `build-schemas.ts` converts with `io: 'output'`, which already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with a known envelope gap; it is a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call. diff --git a/.changeset/system-field-name-injected-columns.md b/.changeset/system-field-name-injected-columns.md new file mode 100644 index 0000000000..919548b26e --- /dev/null +++ b/.changeset/system-field-name-injected-columns.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): SystemFieldName says which columns are actually injected (#4430) + +`SystemFieldName` presents itself as the canonical protocol-level names for +system fields, but it was neither the injected set nor a complete one — and it +had the most load-bearing entry backwards. `TENANT_ID` was documented as +"Tenant isolation key" while the column the registry actually provisions is +`organization_id`, which had no constant at all. Nor did `created_by` / +`updated_by`, the other half of the audit-provenance family. Two of the seven +entries (`user_id`, `deleted_at`) are not injected either, with nothing in the +table saying so. + +Consumers hand-copying a system-field list read the table as the injection set +and drifted accordingly. cloud#982 found three such copies in one package +carrying `tenant_id`, `org_id` and `space` between them — three spellings no +injection site produces — and cloud#979 was one of those copies claiming a +business field named `owner`, so every seeded row of a user's app shipped its +负责人 column blank. + +**Additive only. No entry removed, no value changed**, so existing +`SystemFieldName.X` references are unaffected. + +- Adds `ORGANIZATION_ID`, `CREATED_BY` and `UPDATED_BY` — the three injected + columns the table was missing. +- Records per entry whether open-core actually injects it, so the legacy + (`tenant_id`, stamped from the session's *organization* id only on an object + that declares it) and authored (`user_id`) names can no longer be mistaken + for provisioned ones. +- States in the module doc that this is a NAME registry, not the injected set. + `applySystemFields` decides that per object from `ownership` / `tenancy` / + `systemFields`, so the same name is a system column on one object and + business data on the next. A consumer asking "is this field system-managed on + THIS object" branches on `Field.system` — already published for exactly that + purpose, and now pointed at from here. + +`@objectstack/lint`'s `SYSTEM_FIELDS` is unchanged in content: it unions this +table with `FIELD_GROUP_SYSTEM_FIELDS`, which already carried all three added +names. diff --git a/.changeset/trigger-registry-connector-cluster-removed.md b/.changeset/trigger-registry-connector-cluster-removed.md new file mode 100644 index 0000000000..5b77d57d5f --- /dev/null +++ b/.changeset/trigger-registry-connector-cluster-removed.md @@ -0,0 +1,45 @@ +--- +'@objectstack/spec': major +--- + +The `trigger-registry.zod.ts` Connector cluster is removed (#4499) + +`@objectstack/spec/automation` no longer exports the third declaration of the +connector vocabulary: `ConnectorSchema`, `ConnectorInstanceSchema`, +`ConnectorOperationSchema`, `ConnectorTriggerSchema`, `ConnectorCategorySchema`, +`AuthenticationSchema` / `AuthenticationTypeSchema` / `AuthFieldSchema` / +`OAuth2ConfigSchema`, `OperationTypeSchema` / `OperationParameterSchema`, their +inferred types, and the `Connector.apiKey()` / `Connector.oauth2()` factory +helpers — 630 lines, all of `automation/trigger-registry.zod.ts`. + +Despite the filename, the file contained no trigger registry. Every export was +connector vocabulary, self-contained and read by nothing: + +- the automation engine registers and validates connectors against + `ConnectorSchema` from `integration/connector.zod.ts` (ADR-0097) — it never + imported this one; +- the stack `connectors:` collection parses `DeclarativeConnectorEntrySchema`; +- outside the spec package, the only references anywhere in the monorepo were + the two documentation generators that published it. + +This closes the connector triple-declaration: `integration/connector.zod.ts` +is the one live contract (ADR-0097), the six per-provider "templates" fell in +#4480, and this cluster is the last copy (Prime Directive #12 — one +capability, one contract). + +**Migration.** If you imported any of these names from +`@objectstack/spec/automation`, there is nothing to migrate *to* on that +module: nothing ever consumed what you built against them. Declare real +connector instances with `defineConnector` / the stack `connectors:` collection +(`DeclarativeConnectorEntrySchema`), or materialize them from a provider +document via connector-openapi / connector-mcp. Note the name collision when +migrating types: the live `integration/connector.zod.ts` also exports a +`ConnectorTriggerSchema` and a `Connector` type with *different shapes* — a +find-and-replace of the import path is not a migration. + +The removal also deletes the "When to use Integration Connector vs. Trigger +Registry?" comparison from `integration/connector.zod.ts`'s header, which +steered "lightweight" cases to the dead file with the platform's authority — +the same defect class as the `capabilities.readOnly` prescription #4487 +corrected. No D2 conversion: none of this was storable stack metadata, so +there is no source for `os migrate meta` to rewrite. diff --git a/.changeset/type-bulk-action-defs.md b/.changeset/type-bulk-action-defs.md new file mode 100644 index 0000000000..212be16c2a --- /dev/null +++ b/.changeset/type-bulk-action-defs.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457) + +A selection-bar bulk action was declared as +`z.array(z.record(z.string(), z.any()))` — **no shape at all**. The real +contract lived in objectui's `BulkActionDef` interface and in the executor that +reads it, so every authoring mistake landed as a silent runtime downgrade: +`opeartion` parsed and the executor hit `Unknown operation: undefined` per row; +`excution: 'aggregate'` parsed and the def stayed per-record, so the endpoint +written for ONE `_selectedIds` call got N calls instead — the exact defect +objectui#3139 was filed to make expressible. That is ADR-0018's "second +vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks) +crossed with ADR-0078's silently-inert metadata. + +`ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema` +got in #3746/#4001: a **strict** def whose unknown-key error names the offending +key and the canonical spelling. Beyond spelling, it refuses the combinations the +executor never reads — `patch` outside an `update`, `execution` outside a +`custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a +hand-written `actionDef`, which is attached by the renderer when it resolves the +def's `name` and which authored by hand would smuggle an action definition past +the action registry. + +**One shape that parsed before is now rejected**: `operation: 'custom'` without +`execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly +one authored shape (the aggregate one); every other custom def falls to +`Promise.resolve()` per row — a button that reports success for every selected +record and does nothing. The error names both legal forms: `bulkActions: +['']` for per-record (promoted with the action's own label, params and +`visible`), `execution: 'aggregate'` for one call over the whole selection. + +Two things are deliberately left open: + +- **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a + `[key: string]: unknown` catch-all — widget config (min/max/step/format) + forwarded to the field renderer as-is. Locking it down would reject valid + config, so declared keys are typed and the rest rides through, the same call + `dashboard.zod.ts` makes for a widget's `config`. +- **The bulk-param / action-param spelling divergence** (`help`/`helpText`, + `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which + `ActionParamSchema` has no counterpart for). objectui already owns a converter + for the promoted direction; converging the authored direction is a cross-repo + change with its own migration. Typing them as they are is what makes the + divergence visible rather than undocumented — the prerequisite for closing it. + +`label` and the param/option labels are `z.string()`, not `I18nLabelSchema`: +an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map +on this path) and the bar renders `def.label` as a React child, so blessing the +map form would trade a parse error for a blank screen. Localize by declaring a +real action and naming it in `bulkActions` — that path runs through the i18n +resolver. + +**Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an +`execution: 'aggregate'` entry is a name reference (it is what +`resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id +and resolving it would be nonsense. The walk also reaches an **object's own +`listViews`** for the first time — an object has no top-level `list`, so that +tier had simply never been visited while the view-level ones were covered. And +the hint no longer tells a bulk-surface author to add a `locations` entry: the +selection bar is the one surface that does not filter on it, so naming the +action there is the whole placement. + +Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`. diff --git a/.changeset/unknown-key-strictness-data-step.md b/.changeset/unknown-key-strictness-data-step.md index 7cec7ffe89..452894afd6 100644 --- a/.changeset/unknown-key-strictness-data-step.md +++ b/.changeset/unknown-key-strictness-data-step.md @@ -26,8 +26,12 @@ Deliberately still tolerant: runtime shape the engine hands a handler. Strictness there would turn an engine-internal enrichment (as `provenance` was in #3712) into a breaking change for anyone parsing a context they were given. -- `datasource.config` and `readReplicas` — per-driver by construction; the - driver's own `configSchema` validates them. +- `datasource.config` — per-driver by construction (a sqlite `filename` and a + postgres `host`/`port` share no shape). Left open here and closed one level + down instead: #4410 parses it against the contract for the declared driver. + This bullet used to say "the driver's own `configSchema` validates them", + which was not true when it was written — the field existed and nothing read + it. Errors are self-fixing: connection keys written one level too high (`host`, `port`, `filename`, `url`, …) are prescribed into `config`; a top-level diff --git a/.changeset/user-field-implicit-target.md b/.changeset/user-field-implicit-target.md new file mode 100644 index 0000000000..5ecbdeb7e5 --- /dev/null +++ b/.changeset/user-field-implicit-target.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": patch +"@objectstack/metadata-protocol": patch +--- + +fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless + +`field.zod` defines `user` as "a lookup specialized to the `sys_user` system +object … target fixed to the `sys_user` system object", and `Field.user()` — +unlike `Field.lookup(reference, …)` — takes no target argument and writes +`reference: 'sys_user'` itself. The target is a constant of the type. + +Two callers read `field.reference` raw and so disagreed: the protocol's expand +gate refused `?expand=` with `400 INVALID_FIELD … declares no +target object`, and objectql's expand loop skipped it. Metadata authored without +the redundant `reference` — hand-written JSON, an AI author, a Studio form — was +read as under-specified when it was complete. Live capture (cloud#983): an +AI-built app's very first screen rendered an error page over that 400. + +New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of +"what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the +set those same two callers already share for "is this a reference at all"). Both +halves of the expand path read it, so the gate can no longer refuse a field the +engine would have expanded, nor bless one it skips. diff --git a/.changeset/v17-rest-envelope-defects.md b/.changeset/v17-rest-envelope-defects.md new file mode 100644 index 0000000000..dcce141275 --- /dev/null +++ b/.changeset/v17-rest-envelope-defects.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/metadata-protocol": minor +"@objectstack/driver-sql": minor +"@objectstack/driver-memory": minor +--- + +fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + +Four independent surfaces where the answer a caller received contradicted the +contract the surface declares. All four were found driving a real showcase boot +against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + +- **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + +- **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + +- **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + +- **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + +These change responses that were observably wrong, so callers coded against the +buggy shapes — a 200 on a missing record, a 400 on a capability denial — will +see different status codes. Graded `minor` on that basis rather than `patch`. diff --git a/.changeset/v17-verification-defects-docs.md b/.changeset/v17-verification-defects-docs.md new file mode 100644 index 0000000000..8cfbeefe9d --- /dev/null +++ b/.changeset/v17-verification-defects-docs.md @@ -0,0 +1,25 @@ +--- +--- + +docs+test: v17 verification defects — ReDoS assertion load-insensitivity, doc drift (#4485, #4476, #4486, #4452) + +Release-nothing: touches only `.md`/`.mdx` prose and one `.test.ts` file. No +package source, no public export, no protocol change — so no package needs a +version bump. + +- **#4485** `protocol-handshake.test.ts` — the ReDoS guard bounded the + pathological scan with an absolute 50ms wall clock, which measures machine + load rather than the parser: under the full-repo run (~130 parallel turbo + tasks) it exceeded 50ms on a healthy tree and reddened PRs that never touched + `@objectstack/metadata-core`. The behavioural assertions are kept; the + wall-clock proxy is replaced by a scaling check (same adversarial shapes at 1x + and 8x length), so load largely cancels out of the ratio. +- **#4476** Seventeen passages dated the v17 query-surface removals to + `@objectstack/spec` 18. They ship in 17; `spec-changes.json` gives + `toMajor: 17`. +- **#4486** The `IDataEngine` doc block dropped the trailing + `options?: BaseEngineOptions` from all four read methods — the very parameter + #4251 added, against a failure mode that raises no error. +- **#4452** `service-automation`'s README taught a flow DSL that never existed + (node type names, interpolation dialect, and nested `steps` all wrong); + rewritten from the schemas and executors. README only — no package code. diff --git a/.changeset/verify-harness-durable-suspended-runs.md b/.changeset/verify-harness-durable-suspended-runs.md new file mode 100644 index 0000000000..c31b7a02dc --- /dev/null +++ b/.changeset/verify-harness-durable-suspended-runs.md @@ -0,0 +1,42 @@ +--- +"@objectstack/verify": minor +--- + +fix(verify): stop the harness pinning `suspendedRunStore: 'memory'` (#4470) + +`bootStack` hardcoded `suspendedRunStore: 'memory'` when it registered +`@objectstack/service-automation`. That made the DB-backed suspended-run store +**structurally unreachable** from every dogfood/e2e fixture — not under-tested, +untestable. The coverage map had a clean seam nothing crossed: + +- unit tests covered ENGINE-side persistence (`suspended-run-store.test.ts` + drives suspend → restart → resume against a fake table); +- e2e covered the BUSINESS chain (approvals), but single-process and wholly in + memory; +- the ASSEMBLY between them — is `sys_automation_run` registered, is its table + created, is the store actually attached to the engine — was covered by + neither. + +#4420 grew in precisely that seam: the store hung off a table that was never +created, every write failed into a `warn` nobody read, the pause reported +success, and the run died at the next restart. #4460 added assembly unit tests; +this makes the e2e half possible. + +The harness now boots the plugin's own `'auto'` default — the same wiring +`objectstack dev` / `serve` get — so fixtures exercise the real assembly. Two +new knobs: + +- `automation` accepts `{ suspendedRunStore: 'auto' | 'memory' }` as well as + `true`, so a fixture that wants the old in-memory behaviour asks for it + explicitly rather than getting it by default. +- `databaseFile` backs the in-process SQLite database with a file instead of + `:memory:`, so state can outlive a kernel. + +Answering the question the issue raised — was `'memory'` pinned for speed or +because persistence could not run there? **Speed/simplicity.** The durable path +works in this harness: the accompanying dogfood proof boots with it, and the +whole existing dogfood suite passes on it unchanged (38 files, 239 tests). Note +`databaseFile` does not yet deliver a true cold boot: a second `bootStack` over +the same file reads a database whose tables exist but whose rows are gone — +ordinary records do not survive it either, so it is a harness/driver persistence +gap rather than anything to do with suspended runs, and it is filed as #4518. diff --git a/.changeset/workflow-slot-retired.md b/.changeset/workflow-slot-retired.md new file mode 100644 index 0000000000..cc5b590639 --- /dev/null +++ b/.changeset/workflow-slot-retired.md @@ -0,0 +1,58 @@ +--- +"@objectstack/spec": major +"@objectstack/client": major +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +--- + +refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451) + +The `workflow` slot was ADR-0078's silently-inert declaration at every layer at +once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115 +Evidence 5 — "no code in this repository resolves either slot", verified across +both repositories), an `IWorkflowService` contract with zero implementations, a +`WorkflowProtocol` whose three methods no code ever provided, a discovery +`routes.workflow` field no builder could truthfully populate, and a +`/api/v1/workflow` advertisement for a path no host ever mounted (the +pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that +never existed). The capability it promised is live elsewhere and has been for +majors: record state machines are enforced by the `state_machine` validation +rule, approvals are first-class flow nodes on the approvals runtime +(ADR-0019), and record-triggered automation is lifecycle hooks + +`record_change` flows (`service-automation`). + +FROM → TO: + +- `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` / + `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill. +- `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no + implementation ever existed. Register nothing — use the mechanisms above. +- `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`, + `WorkflowState`, `GetWorkflowStateRequest/Response`, + `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed, + along with the seven published JSON schemas. Delete the import; nothing + ever answered these shapes. +- Discovery `routes.workflow` / `services.workflow` / `features.workflow` + (metadata-protocol + runtime builders) → absent. A reader keying on them + only ever saw `unavailable` / `false`; delete the read. +- `RouterConfig.mounts.workflow` → removed; there was never a surface to + mount at it. +- `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent + routes as `'automation'`. +- `@objectstack/client` re-exports of the four workflow types → removed with + their source. (The `client.workflow.*` methods were already removed earlier + in the v17 cycle — this retires the types they returned.) +- Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the + `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a + `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out + of the product plan (#2462 follow-on). + +The retirement kit: the `workflow-service-slot-retired` semantic migration +(major 17) carries this prescription into `spec-changes.json`, the generated +upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a +discovery response field — never stored in stack metadata — so there is no +load-path conversion and nothing for `os migrate meta` to rewrite; the +21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` +entries for the deleted schemas are dropped deliberately in the same change +(the plugin-runtime precedent: a prescription nobody can receive is noise — +nothing parses these shapes any more). diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index 8c79f9359f..39b2557646 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -244,11 +244,15 @@ Work top to bottom; each line has a gate behind it. `tsc` finds these for you on the tombstone route. - [ ] **Published skills** — `skills/*/SKILL.md` teaching the key (tables, `defineX` examples) — gated by `check:skill-examples` and `check:skill-refs`. -- [ ] **Docs** — `content/docs/**` prose, tables and code blocks. Grep the key, - then read the surrounding files: a removed key hides in a `defineFlow` - example three sections from the reference table. -- [ ] **Release notes** — the `### Dead spec clusters removed` table in - `content/docs/releases/v.mdx` **plus** the upgrade checklist. +- [ ] **Docs** — `content/docs/**` prose, tables and code blocks — **EXCEPT + `content/docs/releases/`, which a code PR must never touch** (AGENTS.md + Documentation Guardrails). Release notes are written centrally at release + time from the changesets + the D2/D3 registries; the per-PR row this list + used to require made `releases/v.mdx` the repo's hottest conflict + magnet. Your changeset (next item) is the input that reaches them. For + the rest of `content/docs/**`: grep the key, then read the surrounding + files — a removed key hides in a `defineFlow` example three sections from + the reference table. - [ ] **Changeset** — `major` for `@objectstack/spec`. AGENTS.md: a breaking changeset must carry the FROM → TO mapping and the one-line fix; it ships as `CHANGELOG.md` in the npm package and is what an upgrading agent greps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12a08969a..c099036112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,13 @@ on: pull_request: branches: - main + # Merge queue: the queue builds each PR as speculatively merged onto the + # current main and only lands it if this workflow is green on that result — + # the race-free version of the "pull main and re-verify before merging" + # discipline (AGENTS.md multi-agent §7/§10). Every workflow that produces a + # branch-protection-required check MUST carry this trigger, or queue builds + # wait forever on a check that never reports. + merge_group: # Superseded runs on the same PR/branch waste runners and delay feedback; # cancel them. Push runs to main group by commit ref as well, so an in-flight @@ -23,15 +30,21 @@ jobs: contents: read pull-requests: read outputs: - docs: ${{ steps.changes.outputs.docs }} - core: ${{ steps.changes.outputs.core }} - console: ${{ steps.changes.outputs.console }} + # On merge_group, everything counts as changed: dorny/paths-filter has no + # merge_group support, and the queue build is the last validation before + # main — the one place a skipped job can never be the right answer. A + # skipped step's output is the empty string (falsy), so `|| 'true'` + # supplies the merge-group value without touching PR/push behavior. + docs: ${{ steps.changes.outputs.docs || 'true' }} + core: ${{ steps.changes.outputs.core || 'true' }} + console: ${{ steps.changes.outputs.console || 'true' }} steps: - name: Checkout repository uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 id: changes + if: github.event_name != 'merge_group' with: filters: | docs: @@ -62,18 +75,37 @@ jobs: - '.github/workflows/ci.yml' test: - name: Test Core + # Sharded 2-way BY PACKAGE: a core-touching PR ran the affected suite + # ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow. + # scripts/partition-test-shards.mjs splits the package list into two + # deterministic, test-file-count-balanced halves (573/572 at the time of + # writing) and each shard runs its half through turbo. NOT the dogfood + # job's vitest --shard passthrough, deliberately: that works for dogfood + # because it is ONE package with ~60 files, but applied workspace-wide, + # vitest 4 hard-fails every package with fewer test files than the shard + # count — and `--passWithNoTests` converts the failure into running NOTHING + # on either shard (three packages have exactly one test file today). See + # the script header for the verification. + # + # Branch protection requires the bare "Test Core" context, which a matrix + # can never publish again — the test-gate job below carries that name + # (the #3622 lesson; see dogfood-gate). + name: Test Core (${{ matrix.shard }}/2) needs: filter if: needs.filter.outputs.core == 'true' runs-on: ubuntu-latest # Backstop only — the stall guard on the test steps is the primary # detector for a #4250-style hang and fires well before this. 30 min is - # 2.5-3× a normal run (main ~9.5 min, PR ~12 min), with margin for a cold - # Turbo cache; the old 45 left a hung job "running" for half an hour past - # any plausible healthy finish. + # ~4× a normal sharded run (~6-7 min), with margin for a cold Turbo cache; + # the old 45 left a hung job "running" for half an hour past any plausible + # healthy finish. timeout-minutes: 30 permissions: contents: read + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout repository @@ -110,14 +142,19 @@ jobs: # the repo's 10 GB Actions cache pool and evicted the main-branch seeds — # observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall # back to main's entries via the prefix restore-keys; only main pushes - # save (the "Save Turbo cache" step at the end of the job). + # save (the "Save Turbo cache" step at the end of the job). Shard-scoped + # key: each shard builds/tests a different half of the workspace (same + # pattern as dogfood). Jobs that used to fall back to this job's + # namespace fall back to Build Core now — neither single shard builds a + # superset anymore. - name: Restore Turbo cache uses: actions/cache/restore@v6 with: path: .turbo/cache - key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }} restore-keys: | - ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}- ${{ runner.os }}-turbo-${{ github.job }}- - name: Install dependencies @@ -126,16 +163,41 @@ jobs: # PRs: only test packages affected by the diff against the PR base. # spec sits at the root of the dependency graph, so spec-touching PRs # still run (close to) everything — but the many PRs that don't touch - # spec skip the bulk of the 75-package matrix. - # --concurrency=4: turbo's default (10) oversubscribes the 4-vCPU - # hosted runner; matching the core count bounds peak memory and the - # job is CPU-bound anyway. + # spec skip the bulk of the 75-package matrix. Push to main and + # merge-queue builds partition the FULL package list instead: the queue + # result IS the next main, so it gets main's validation, not the PR's + # affected-only subset. (Spec's suite runs here plain / uninstrumented; + # the coverage-instrumented pass lives in the nightly coverage-nightly + # workflow.) + # # !@objectstack/dogfood: the ~7½-minute dogfood suite is the dedicated # Dogfood job's whole purpose, and both jobs run under the same `core` # filter — without the exclusion every core PR executed the suite twice - # in parallel, and it dominated this job's critical path. The exclusion - # subtracts from the affected set (verified: turbo unions inclusive - # filters, then applies `!` negations to the result). + # in parallel, and it dominated this job's critical path. + # + # `turbo ls` is experimental; the partition script asserts its output + # shape loudly so an upgrade that changes it turns into a red step + # naming the cause, not a silently empty shard. An EMPTY shard file must + # short-circuit the test step below: `turbo run test` with zero --filter + # args runs the entire workspace. + - name: Compute this shard's package set + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + pnpm exec turbo ls --affected --output=json > "$RUNNER_TEMP/turbo-ls.json" + else + pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json" + fi + node scripts/partition-test-shards.mjs "$RUNNER_TEMP/turbo-ls.json" \ + --shard ${{ matrix.shard }}/2 --exclude @objectstack/dogfood \ + > "$RUNNER_TEMP/shard-packages.txt" + echo "Packages on this shard:" + cat "$RUNNER_TEMP/shard-packages.txt" + + # --concurrency=4: turbo's default (10) oversubscribes the 4-vCPU + # hosted runner; matching the core count bounds peak memory and the + # job is CPU-bound anyway. # run-with-stall-guard replaces the old `… 2>&1 | tee $log` + # `set -o pipefail` idiom: the guard tees combined output to the log # itself and propagates the suite's real exit status, so there is no @@ -144,7 +206,7 @@ jobs: # job sits in_progress. Silence past --stall-minutes is declared a # stall — a labeled red naming the last output line — instead of a # 20-minute wait for a human (or the job timeout) to notice. 10 min is - # ~5× the longest healthy quiet gap and still under half a normal run. + # ~5× the longest healthy quiet gap. # # NODE_OPTIONS arms every node process (vitest workers included) to dump # a diagnostic report on SIGUSR2; on a stall the guard signals the frozen @@ -152,32 +214,19 @@ jobs: # a process whose event loop is alive, and a named "no report = blocked # loop" verdict for one that is sync-spinning. The next #4250 occurrence # identifies its own culprit instead of costing a diagnosis. - - name: Run affected tests (PR) - if: github.event_name == 'pull_request' - env: - TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} - NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports - run: | - mkdir -p "$RUNNER_TEMP/stall-reports" - node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \ - --report-dir "$RUNNER_TEMP/stall-reports" -- \ - pnpm turbo run test --affected --filter=!@objectstack/dogfood --concurrency=4 - - # Push to main: full run. Spec's suite runs here plain (uninstrumented); - # the coverage-instrumented pass moved to the nightly Spec Coverage - # workflow (coverage-nightly.yml) — instrumentation added minutes to - # every main push for a trend artifact that is consulted occasionally at - # best. Dogfood is excluded for the same reason as the PR step: the - # Dogfood job runs it. - - name: Run all tests (push) - if: github.event_name == 'push' + - name: Run this shard's tests env: NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports run: | + if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then + echo "No packages on this shard — nothing to test." + exit 0 + fi + FILTERS=$(sed 's/^/--filter=/' "$RUNNER_TEMP/shard-packages.txt" | tr '\n' ' ') mkdir -p "$RUNNER_TEMP/stall-reports" node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \ --report-dir "$RUNNER_TEMP/stall-reports" -- \ - pnpm turbo run test --filter=!@objectstack/dogfood --concurrency=4 + pnpm turbo run test $FILTERS --concurrency=4 # Runs even when the suite failed — that is when it earns its keep. A red # suite plus a GREEN completeness check means real test failures; a red @@ -195,25 +244,52 @@ jobs: # A stall's full diagnostic reports (JS stacks, libuv handles, heap # summary per process) outlive the in-log digest — keep them so a #4250 # occurrence can be dissected offline. Free when nothing stalled: the - # directory is empty and if-no-files-found skips the upload. + # directory is empty and if-no-files-found skips the upload. Shard-scoped + # name so the two matrix jobs don't collide. - name: Upload stall diagnostic reports if: failure() uses: actions/upload-artifact@v7 with: - name: stall-reports-test-core + name: stall-reports-test-core-${{ matrix.shard }} path: ${{ runner.temp }}/stall-reports/ if-no-files-found: ignore retention-days: 14 # Seed the shared Turbo cache from main only (see the restore step # above). always(): keep the seed fresh even when a test fails, matching - # the old actions/cache post-step behavior. + # the old actions/cache post-step behavior. Shard-scoped key so the two + # matrix jobs don't collide. - name: Save Turbo cache (main only) if: always() && github.event_name == 'push' uses: actions/cache/save@v6 with: path: .turbo/cache - key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }} + + test-gate: + # Stable required-check name for the sharded Test Core matrix — the exact + # contract dogfood-gate documents below (#3622): branch protection requires + # the bare "Test Core" context, and once the job is a matrix that context + # can never appear again, deadlocking every PR. Keeping the contract HERE + # means a future shard-count change cannot deadlock the repo. See + # dogfood-gate for why `cancelled` passes and why this must not be + # `if: !cancelled()` on the job. + name: Test Core + needs: test + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Verify test shard results + run: | + result="${{ needs.test.result }}" + echo "test matrix aggregate result: $result" + case "$result" in + success|skipped|cancelled) echo "Test Core gate satisfied ($result)." ;; + *) echo "::error::Test Core shards did not pass (aggregate result: $result)"; exit 1 ;; + esac # ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ───── @@ -317,9 +393,11 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store-v3- - # Restore-only (same policy as every other job); falls back to the Test - # Core namespace because that job builds a superset of what this one - # needs and its cache is seeded from main. + # Restore-only (same policy as every other job); falls back to the Build + # Core namespace because that job builds every package (a superset of the + # build closure this one needs) and its cache is seeded from main. It + # used to fall back to Test Core, but that namespace is per-shard now and + # neither single shard builds a superset. - name: Restore Turbo cache uses: actions/cache/restore@v6 with: @@ -327,8 +405,8 @@ jobs: key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo-${{ github.job }}- - ${{ runner.os }}-turbo-test-${{ github.ref_name }}- - ${{ runner.os }}-turbo-test- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- - name: Install dependencies run: pnpm install --frozen-lockfile @@ -424,8 +502,9 @@ jobs: if: needs.filter.outputs.core == 'true' runs-on: ubuntu-latest # Backstop only — the stall guard on the test step is the primary detector - # for a #4250-style hang (see Test Core). 30 min is ~2.5× the slower shard - # (shard 1 runs the verify-CLI step too, ~12 min all in). + # for a #4250-style hang (see Test Core). 30 min is ~4× a shard (~7 min; + # the verify-CLI pass that used to ride shard 1 is its own parallel job + # now — dogfood-verify below). timeout-minutes: 30 permissions: contents: read @@ -523,21 +602,6 @@ jobs: if-no-files-found: ignore retention-days: 14 - # Replaces the former auto-verify dogfood tests: runs the published - # `objectstack verify` engine over each example app through the CLI — - # auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant. - # Exits non-zero on a real runtime failure, so it gates like the tests did. - # Not shard-dependent, so shard 1 alone runs it. - - name: Verify example apps via the `objectstack verify` CLI - if: matrix.shard == 1 - run: | - pnpm turbo run build --filter=@objectstack/cli - for app in examples/app-crm examples/app-showcase; do - echo "::group::objectstack verify $app --rls" - OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app "$app/objectstack.config.ts" --rls - echo "::endgroup::" - done - # Seed the shared Turbo cache from main only (see the restore step # above); shard-scoped key so the two matrix jobs don't collide. - name: Save Turbo cache (main only) @@ -547,6 +611,91 @@ jobs: path: .turbo/cache key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }} + # Replaces the former auto-verify dogfood tests: runs the published + # `objectstack verify` engine over each example app through the CLI — + # auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant. + # Exits non-zero on a real runtime failure, so it gates like the tests did. + # + # Its own job, not a rider on dogfood shard 1: the ~4½-minute pass ran + # SERIALLY after that shard's tests, making shard 1 (~12 min) nearly twice + # shard 2 (~6½ min) — the second-longest pole in the workflow for no + # parallelism reason. It reports through dogfood-gate (below) rather than + # its own required context, so the branch-protection contract is unchanged. + dogfood-verify: + name: Dogfood Verify CLI + needs: filter + if: needs.filter.outputs.core == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - name: Verify pnpm version + run: pnpm --version + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + # Restore-only, with no save step at all (the console-pin pattern): the + # job's only build is the CLI closure, and the build-core fallbacks are + # the entries that actually hit — that job builds a superset and is + # seeded from main. + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Build the CLI plus BOTH example apps' full dependency closures (the + # `pkg...` filter form). When this pass rode dogfood shard 1 the whole + # workspace was already built by the test step, so `--filter=@objectstack/cli` + # alone sufficed; standalone, the bundled objectstack.config imports + # runtime packages (e.g. @objectstack/connector-mcp for app-showcase) + # whose dist nothing here had built — ERR_MODULE_NOT_FOUND at verify + # time. The closure syntax keeps this self-maintaining as app deps move. + - name: Verify example apps via the `objectstack verify` CLI + run: | + pnpm turbo run build \ + --filter=@objectstack/cli... \ + --filter=@objectstack/example-crm... \ + --filter=@objectstack/example-showcase... \ + --concurrency=4 + for app in examples/app-crm examples/app-showcase; do + echo "::group::objectstack verify $app --rls" + OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app "$app/objectstack.config.ts" --rls + echo "::endgroup::" + done + dogfood-gate: # Stable required-check name for a SHARDED job (#3622 follow-up). # @@ -558,10 +707,14 @@ jobs: # branch protection; keeping the contract HERE instead means a future # shard-count change cannot deadlock the repo a second time. # + # Also aggregates dogfood-verify (the CLI pass that used to ride shard 1), + # so the one required context still covers everything it covered before + # the split. + # # `if: always()` + result inspection so a legitimately skipped matrix (the # `filter` job says no core paths changed) still satisfies the gate. name: Dogfood Regression Gate - needs: dogfood + needs: [dogfood, dogfood-verify] if: always() runs-on: ubuntu-latest timeout-minutes: 10 @@ -585,10 +738,16 @@ jobs: # Deliberately NOT `if: !cancelled()` on the job instead: a skipped # gate publishes no required-check context on the SHA, which is the # #3622 merge-deadlock all over again. - case "$result" in - success|skipped|cancelled) echo "Dogfood gate satisfied ($result)." ;; - *) echo "::error::Dogfood shards did not pass (aggregate result: $result)"; exit 1 ;; - esac + verify_result="${{ needs['dogfood-verify'].result }}" + echo "dogfood-verify result: $verify_result" + fail=0 + for r in "dogfood:$result" "dogfood-verify:$verify_result"; do + case "${r#*:}" in + success|skipped|cancelled) echo "Gate leg ${r%%:*} satisfied (${r#*:})." ;; + *) echo "::error::Gate leg ${r%%:*} did not pass (result: ${r#*:})"; fail=1 ;; + esac + done + exit "$fail" build-core: name: Build Core diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 151af2c049..54fbebf852 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,10 @@ on: pull_request: branches: - main + # Merge queue (see ci.yml for the full note): required checks must report on + # queue builds or the queue stalls. This workflow has no PR-only steps, so + # the trigger alone is enough. + merge_group: # Same policy as ci.yml: superseded runs on the same PR/branch waste runners # and delay feedback; cancel them. Push runs to main group by commit ref, so an @@ -436,6 +440,17 @@ jobs: - name: Check no exported spec type resolves to `any` run: pnpm --filter @objectstack/spec run check:exported-any + # Third axis on the same surface: api-surface.json shows a name on two + # entries but not whether the two are ONE declaration re-exported (fine) + # or TWO declarations sharing a name — the #4411 trap, where which type a + # consumer gets depends on nothing but the import path and the copy that + # LOOKS canonical can be the dead one. Judged by symbol identity against + # the built dist; existing dual-sources live in a shrink-only baseline + # (dual-source-exports.baseline.json), so only a NEW one fails (#4446). + # Self-tests first, like exported-any. + - name: Check no new same-name dual-source spec exports + run: pnpm --filter @objectstack/spec run check:dual-source-exports + # Anti-drift for the skill EXAMPLES, not just the skill reference indexes # (#3094). The TypeScript in skills/ is the first thing an AI copies when # authoring metadata, yet nothing type-checked it — so it rotted silently diff --git a/.github/workflows/spec-liveness-check.yml b/.github/workflows/spec-liveness-check.yml index 997e7a4d32..5c7a5f8b2d 100644 --- a/.github/workflows/spec-liveness-check.yml +++ b/.github/workflows/spec-liveness-check.yml @@ -24,6 +24,10 @@ on: # Same for the strictness ledger — it is a doc, and editing it can break # the gate that now holds it to the code. - 'docs/audits/**' + # Merge queue (see ci.yml for the full note). merge_group has no `paths` + # support, so queue builds run this unconditionally — acceptable: the whole + # job is ~a minute, and the queue result is the next main. + merge_group: permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index d47db92f67..57307ca3a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,24 @@ this is mandatory, not a preference (Prime Directive #11), and a PreToolUse hook blocks edits made while on the shared `main` branch. Working in the shared `main` checkout is *not* a supported fallback: branches get switched and shared files — including ones you just wrote — get reset *under you* mid-task (a full session's -work was silently reverted twice before this rule was enforced). Even inside your -own worktree, operate defensively: +work was silently reverted twice before this rule was enforced). + +**Claim the issue BEFORE you write any code.** Assign it to yourself +(`gh issue edit --add-assignee @me`, or the `issue_write` MCP tool with +`assignees`) as the *first* action of the task — before the worktree, before the +first read. An unassigned issue reads as an open invitation, and several agents +work this repo at once: two that both start on it burn the same hours twice and +then race to land conflicting shapes for the same problem, which is worse than +either one alone. If it is already assigned to someone else it is taken — pick +another, or say so and ask; never reassign it to yourself. + +The claim is also what makes the *finding* rule (Prime Directive #10) safe to +follow. Once out-of-scope discoveries become issues, the issue list is a real +queue other agents read, and a claim is the only thing separating "someone is on +this" from "nobody has looked yet". File it unassigned when you are merely +recording a finding; assign it at the moment you actually start. + +Even inside your own worktree, operate defensively: 1. **Only touch the files your task needs.** Don't "fix" unrelated diffs, reverts, or other agents' in-flight edits, and don't try to manage the whole @@ -130,6 +146,16 @@ own worktree, operate defensively: Auto-merge can land a still-red PR onto shared `main` and break it for every parallel agent (see #1475). Merge serially; rebase other open branches before merging the next one. + **Once the repo's merge queue is enabled, "add to queue" IS the sanctioned + path** — it is the opposite of the auto-merge this rule bans: the queue + builds your PR *as merged onto the current `main`* and lands it only if that + speculative result is green, which is exactly the §10 re-verification, done + by the platform, race-free. The manual serial protocol above is the fallback + for when the queue is unavailable. (Why this matters: `main` can land a PR + every few minutes at peak; a manual merge–reverify loop takes ~25 minutes, + so under load it *never* wins the race — one PR went three full green + cycles without managing to land. That is a livelock, not a discipline + failure.) 8. **Testing needs a server? Start your own temporary one — never stop someone else's.** A running dev server you didn't start probably belongs to another agent or the user; killing it (or its port) breaks their in-flight work. Spin @@ -158,15 +184,31 @@ own worktree, operate defensively: None of this is CI-visible: CI checks out fresh and installs clean. It costs only *your* time, which is exactly why it is worth recognising in one step rather than re-diagnosing per gate. -10. **A clean merge is not a working merge.** Git conflicts on overlapping lines; - nothing warns you when two changes are individually fine and jointly wrong. - Real examples from one branch's lifetime: a test asserting a response body's - exact shape landed while that shape was being changed elsewhere (merged clean, - failed CI); a domain file was deleted while another agent's guard still - declared it. **Before opening a PR, and again before merging, pull `main` and - re-run the suite** — the second CI round is where these surface, and the guards - in `scripts/check-*.mjs` exist largely because this class of breakage is - invisible to `git merge`. +10. **A clean merge is not a working merge — but scope the re-check to the + overlap.** Git conflicts on overlapping lines; nothing warns you when two + changes are individually fine and jointly wrong. Real examples from one + branch's lifetime: a test asserting a response body's exact shape landed + while that shape was being changed elsewhere (merged clean, failed CI); a + domain file was deleted while another agent's guard still declared it. + **Before opening a PR, pull `main`, refresh build state (§9), and run the + full suite once.** For the *subsequent* pre-merge merges of `main` — the + ones you do only because `main` moved again while CI ran — the full suite is + usually re-proving what three identical runs already proved, at ~15 minutes + per lap while `main` lands a PR every few. Scope it instead: + - **Always:** rebuild what the merge touched, and if `packages/spec` moved + on either side, `pnpm --filter @objectstack/spec build && pnpm --filter + @objectstack/spec check:generated` — generated snapshots (`api-surface`, + baselines) are the classic jointly-wrong artifact, and only a rebuild of + the merged source can validate them (never trust git's textual merge of a + generated file). Then assert your branch's *delta vs `main`* is still + exactly what your PR intends (e.g. "N removed / 0 added"). + - **Full `pnpm typecheck && pnpm test` again only when** the incoming + commits touch the same packages or the same behavior your diff does, or a + conflict occurred outside trivially-mechanical files. + - CI on the PR (and the merge queue, once enabled) validates the merge + commit itself — that second CI round is where joint breakage surfaces, and + the guards in `scripts/check-*.mjs` exist largely because this class of + breakage is invisible to `git merge`. --- @@ -239,6 +281,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d | Path | Type | Rule | |:---|:---|:---| | `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. | +| `content/docs/releases/` | **RELEASE-OWNED** | ❌ Never edit in a code PR. Release notes are written **centrally at release time**, compiled from changesets + the ADR-0087 registries — not accreted a row per PR. Per-PR appends made `releases/v.mdx` the repo's hottest conflict magnet (three PRs raced the same table inside one afternoon), and every manual resolution risks dropping someone else's row. Your PR's input is its **changeset**; for spec removals also the D2/D3 registry entries. Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes. | | `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode — every existing translation is preserved); `pnpm i18n:extract` still covers `platform-objects` alone. Translation *values* are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. | | `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. | | `content/docs/concepts/` | hand-written | ✅ | @@ -299,10 +342,25 @@ removals" this way while writing this section; `check:generated` now prints this inline when that gate is the one failing.) `check:liveness`, `check:empty-state`, `check:skill-examples`, -`check:react-conformance` and `check:exported-any` are pure checks with no generator — a -failure there is a real finding to fix, not an artifact to regenerate. `check:generated` -names them as deliberately not run, so its "all up to date" never reads as "everything -passed". +`check:react-declaration-parity`, `check:exported-any` and `check:dual-source-exports` are +pure checks with no generator — a failure there is a real finding to fix, not an artifact +to regenerate. `check:generated` names them as deliberately not run, so its "all up to +date" never reads as "everything passed". The last one asks the third question about the +export surface (#4446): `api-surface.json` shows a name on two entries but not whether +that is one declaration re-exported (fine) or two declarations sharing a name — the #4411 +trap, judged by symbol identity against the built dist, with the accepted cases in the +shrink-only `dual-source-exports.baseline.json` (hand-edited under review, never +generated: a `gen:` would admit a new dual-source via "run the fix command"). + +⚠️ **`check:react-declaration-parity` compares two DECLARATIONS, not a declaration against +an implementation.** Left: the props a block's spec zod schema declares. Right: the inputs +the objectui *registry config* declares. Both are declarations — `manifestFromConfigs` +copies `config.inputs` verbatim — so a prop **both sides declare and no renderer reads** +is, to this gate, perfect agreement. It was named `check:react-conformance` and opened by +claiming it confirmed the components "ACTUALLY implement" the spec props; it never could, +and #4413 shipped four dead blocks straight through a green run of it. Renamed and +re-scoped in #4472. The gate is still worth having (`spec-only`, `registry-only` and +`missing` are real signals) — just don't read it as proof anything renders. `check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the stale-`dist` caveat above applies to it too. It asks the other half of the diff --git a/CLAUDE.md b/CLAUDE.md index aa8db33739..060059c78f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,19 @@ # CLAUDE.md **[AGENTS.md](./AGENTS.md) is the source of truth for working in this repo — read it.** -Its Prime Directives are binding. Do not rely on this file alone; the one rule that must -never be missed is inlined here because missing it corrupts other agents' work. +Its Prime Directives are binding. Do not rely on this file alone; the three rules that +must never be missed are inlined here because missing any one of them wastes or corrupts +other agents' work. + +## ⛔ Claim the issue before you write any code + +Assign the issue to yourself (`gh issue edit --add-assignee @me`, or `issue_write` +with `assignees`) as the **first action of the task** — before the worktree, before the +first read. Several agents work this repo at once and an unassigned issue reads as an +open invitation: two that both start on it burn the same hours twice, then race to land +conflicting shapes for one problem. Already assigned to someone else? It is taken — pick +another or ask; never reassign it to yourself. File findings unassigned when you are only +recording them; assign at the moment you start. ## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11) @@ -23,5 +34,17 @@ Then make all edits there. This applies **per repo**: if a task spans `framework file's own repo (so sibling repos are covered). Deliberate non-task exception: `OS_ALLOW_MAIN_EDITS=1`. Follow the rule because it's correct, not because the hook fires. +## ⛔ Never edit `content/docs/releases/` in a code PR + +Release notes are written **centrally, at release time** — not accreted one PR at a +time. Every code/feature/retirement PR appending its own row to the current +`releases/v.mdx` turns that file into the single hottest merge-conflict magnet in +the repo (with ~18 merges to `main` in a working day, the same table conflicts over and +over, and each resolution risks dropping someone else's row). Your PR's inputs to the +release notes are the **changeset** (`.changeset/*.md` — one file per change, never +conflicts) and, for spec removals, the ADR-0087 registries; the release process compiles +them. If you believe a releases page has a factual error, file an issue or make it a +dedicated docs-only PR — never a rider on code changes. + See **AGENTS.md** for the full playbook: branch hygiene, the dev stack, PR flow, and the rest of the Prime Directives. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index bb473d6494..188d3656a4 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -187,6 +187,10 @@ const bad = await client.meta.getDiagnostics({ severity: 'error' }); const refs = await client.meta.getReferences('object', 'account'); const trail = await client.meta.getAudit('object', 'account', { limit: 20 }); const tree = await client.meta.getBookTree('handbook'); + +// Operator: rewrite stored rows into today's canonical shape (ADR-0087). +// Preview unless `apply: true`; requires the `manage_metadata` capability. +const report = await client.meta.migrateStored({ apply: true }); ``` ### `client.data` — CRUD & Batch diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 0bcd2567b1..76ebd90bb5 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -110,10 +110,41 @@ substitute. See the [Data API](/docs/api/data-api). **Retry:** `no_retry` ### `INVALID_REFERENCE` -**Cause:** A `lookup` or `master_detail` field references a record that does not exist. -**Fix:** Verify the referenced record ID exists in the target object. +**Cause:** Reserved for an invalid foreign-key reference. **No route emits it +today.** A `lookup` / `master_detail` pointing at a record that does not exist +is refused as a *field-level* failure instead — see below. +**Fix:** Do not branch on this code; branch on `VALIDATION_FAILED` + +`fields[].code === 'reference_not_found'`. **Retry:** `no_retry` + +**A dangling reference answers `VALIDATION_FAILED`, not `INVALID_REFERENCE`** (#4441). +Writing a `lookup` / `master_detail` value with no matching row in the target +object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in +`fields[]` — which names the field, the target object and the unresolvable id: + +```json +{ + "error": "Permission Set: no sys_permission_set record has id \"ps_missing\"", + "code": "VALIDATION_FAILED", + "fields": [{ + "field": "permission_set_id", + "code": "reference_not_found", + "label": "Permission Set", + "constraint": { "target": "sys_permission_set" }, + "value": "ps_missing" + }] +} +``` + +The check covers create, update and bulk update. Three cases are deliberately +*not* rejections: an empty value (`null` / `""` / `[]`) means "no link", a +`isSystem` write is exempt (seed replay and package install legitimately write +in an order that only resolves once the batch completes), and a target that +cannot be checked at all — an unregistered object, an unreachable datasource — +fails **open** rather than inventing a rejection. + + ### `DUPLICATE_VALUE` **Cause:** A field with `unique: true` already has a record with the same value. **Fix:** Use a different value or update the existing record. diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index 4463d58791..d48504a35a 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -26,19 +26,21 @@ Authenticate with email and password (better-auth's email sign-in route, mounted The following endpoints become available when the corresponding plugin is installed and registered with the kernel. Use the discovery `services` map to check availability. -### Workflow (`/workflow`) — Plugin Required +### Workflow (`/workflow`) — removed in v17 -Not yet mounted. These routes are declared in the API protocol but the core dispatcher registers no `/workflow` handler and no bundled plugin provides a `workflow` service (only an in-memory dev stub), so they return **404** today. `discovery.services.workflow` reports `unavailable` in a standard install. +There is no workflow endpoint, and there is no `workflow` service slot. The +three routes documented here were declared in the API protocol and served by +nothing — no dispatcher handler, no plugin — so they 404'd for the whole life +of the declaration. The slot, the `WorkflowProtocol` methods behind it and the +discovery fields that reported it were all retired in v17 ([#4451](https://github.com/objectstack-ai/objectstack/issues/4451)). +Use the live mechanisms instead: an object validation rule of type +`state_machine` for lifecycle transitions, an `approval` flow node for human +approval pauses (ADR-0019), and lifecycle hooks / `record_change` flows for +record-triggered automation. -| Method | Endpoint | Description | -|:-------|:---------|:------------| -| GET | `/workflow/:object/config` | Get workflow configuration | -| GET | `/workflow/:object/:recordId/state` | Get record's workflow state | -| POST | `/workflow/:object/:recordId/transition` | Execute state transition | - -Approve/reject are **not** workflow routes (ADR-0019): approval is a flow node, and decisions are recorded on the approvals runtime via `POST /approvals/requests/:id/approve` and `POST /approvals/requests/:id/reject`. +Approve/reject were never workflow routes (ADR-0019): approval is a flow node, and decisions are recorded on the approvals runtime via `POST /approvals/requests/:id/approve` and `POST /approvals/requests/:id/reject`. ### Automation (`/automation`) — Plugin Required diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index d348da984d..6696828f1a 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -477,6 +477,19 @@ approver, or **recall** it — releasing the lock. An admin decision is authoritative: it finalizes the node even under `unanimous`/`quorum`/`per_group`, and is audited under the admin's own id. Prefer a guaranteed-staffed fallback approver so the set is never empty in the first place. + +Note the rule is **"the actor is an admin"**, not "the slate is unstaffed" — so +an admin can also act on a request whose slate *is* properly staffed, bypassing +the people on it. That is why the decision records **which door it came +through**: `sys_approval_action.via_override` is `true` when the actor was +admitted *only* by this privileged path, holding no slot themselves. An admin who +is also a designated approver is approving normally and records `false` — the +flag is about the branch that authorized the call, not about who holds admin +rights. A row written before the column existed carries no value at all, which +reads as *not recorded* rather than as *not an override*. Without it, an +override and an ordinary approval were byte-for-byte identical, and the only +trace that a slate had been bypassed was the designated approver's later +`409 INVALID_STATE` — if they happened to try. @@ -491,6 +504,18 @@ The sweep only ever acts on a run it can positively confirm is terminal: a paused run (the normal state of a live approval), an unknown run, or an unreachable automation engine all count as *alive* and are left untouched. It frees orphaned records; it never cancels a live approval. + +That sweep scans **pending** requests, which leaves one shape outside it: a +request already *decided* — `approved`, `rejected` or `returned` — whose run has +since vanished. The decision landed and the flow never moved, and flipping the +request out of `pending` is precisely what removed it from the sweep's view. A +second, **read-only** inspection rides the same clock for those: it reports a +terminal request only when the suspension store says no live pause exists **and** +no terminal run record exists either, skipping (never condemning) any row whose +store could not be read. It deliberately **does not rewrite** them — the decision +really happened, and rolling it back automatically would put the audit trail at +odds with the facts — so it names the stuck requests, their step, and the stale +mirrored status for an operator to act on. ### Progress and notification deep links diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 0db5623b40..b5b045ed8e 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -31,10 +31,11 @@ const approvalFlow = { type: 'decision', label: 'Check Amount', config: { - // decision expressions use {braces} around variables — see Expressions in flows + // decision expressions are bare CEL, like every other condition — no + // braces; see Expressions in flows conditions: [ - { label: 'High Value', expression: '{order_amount} > 10000' }, - { label: 'Standard', expression: '{order_amount} <= 10000' }, + { label: 'High Value', expression: 'order_amount > 10000' }, + { label: 'Standard', expression: 'order_amount <= 10000' }, ], }, }, @@ -112,7 +113,7 @@ Each node performs a specific action in the flow. | `get_record` | Query records | | `http` | Make an HTTP API call | | `notify` | Send an outbound notification via the messaging service | -| `script` | Call a named callable — a registered function (`config.function`) or a built-in side-effect marker (`config.actionType`) | +| `script` | Call a registered function named by `config.function` | | `screen` | Display a user form/screen (durable pause) | | `wait` | Pause for a timer or named signal (durable pause; timers auto-resume) | | `subflow` | Invoke another flow — a pause inside the child suspends both runs as a linked chain | @@ -168,9 +169,10 @@ or missing-`required` violation (#4277). A node type that publishes no type: 'decision', label: 'Check Status', config: { + // Bare CEL — the labels must match this node's out-edge labels exactly. conditions: [ - { label: 'Approved', expression: "{status} == 'approved'" }, - { label: 'Rejected', expression: "{status} == 'rejected'" }, + { label: 'Approved', expression: "status == 'approved'" }, + { label: 'Rejected', expression: "status == 'rejected'" }, ], }, } @@ -212,25 +214,40 @@ or missing-`required` violation (#4277). A node type that publishes no **Script:** The built-in `script` executor never evaluates an arbitrary JavaScript string — -it **names a callable**. Two forms: - -- **`config.function`** — a function registered through - `defineStack({ functions })`. `config.inputs` is `{var}`-interpolated and - handed to it; `config.outputVariable` binds the returned value as a flow - variable, so a later declarative node persists it. This is the supported way - to run server logic. -- **`config.actionType`** — one of the two built-in side-effect markers, - `'email'` or `'slack'`. These are **logger-backed**: they record the intent - and succeed, they do not deliver anything — reach for a `notify` node when you - want real delivery. Any other `actionType` value is treated as a - registered-function name — except the marker `'invoke_function'`, which means - "call the function named in `config.function`" and errors if that key is - missing. - -Inline `config.script` (a JS source body) is *recognized* but **not executed** — -the built-in runtime has no server-side JS sandbox, so such a node warns and -no-ops. A script node that names neither a built-in action nor a registered -function fails the step loudly rather than passing silently. +it **calls a registered function**, and that is the whole of what it does. + +**`config.function`** names a function registered through +`defineStack({ functions })`, and it is **required**. `config.inputs` is +`{var}`-interpolated and handed to it; `config.outputVariable` binds the +returned value as a flow variable, so a later declarative node persists it. A +node that names no function refuses before it runs; one naming a function +nothing registered fails the step loudly rather than passing silently. + + + +`config.actionType`, `config.template`, `config.recipients`, `config.variables` +and inline `config.script` were removed in `@objectstack/spec` 17 ([#4343]). +None of them ran: the `'email'` / `'slack'` action types were **logger-backed +stubs** that recorded the intent, reported success and delivered nothing under +any configuration, and an inline JS body was recognized but never executed (the +built-in runtime has no server-side sandbox). Every other `actionType` value was +shorthand for a registered-function name. + +Replace them per branch — they are different mechanisms, not one rename: + +| Retired shape | Use instead | +| --- | --- | +| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a [`notify` node](#notify) — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | +| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook | +| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | +| inline `config.script` | move the logic into a registered function and call it via `config.function` | + +Stored flows are rewritten by `os migrate meta --from 16`; authoring one of +these keys in TypeScript is now a compile error carrying the same prescription. + +[#4343]: https://github.com/objectstack-ai/objectstack/issues/4343 + + ```typescript { @@ -466,7 +483,7 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume | Pausing node | Suspends until… | Resumed by | | :--- | :--- | :--- | | `approval` | a human decision | the approvals service (`POST /api/v1/approvals/requests/:id/approve\|reject`) — resumes down the matching `approve` / `reject` edge. **Decide through the approvals API**; the resume route above **refuses** an approval pause outright (see below). | -| `screen` | a user submits the form | the UI runner posting the collected `inputs`; a `paused` response carrying the next `screen` chains multi-step wizards under one stable `runId` | +| `screen` | a user submits the form | the UI runner posting the collected `inputs` — **validated server-side against the screen's declared `fields`** (see below); a `paused` response carrying the next `screen` chains multi-step wizards under one stable `runId` | | `wait` (timer) | an ISO-8601 duration elapses | **automatically** — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately) | | `wait` (signal) | a named external event | any caller invoking `resume(runId)` | @@ -511,6 +528,48 @@ key. A reserved name answers **400**, nothing is applied (not even legitimate keys sent alongside it), and the run stays parked. Ordinary author variables are unaffected, `$` mid-name (`price$`) included. +### A `screen` resume is checked against the declared fields + +A screen node's `config.fields` is a **contract**, not just a rendering hint: +the author declares which keys are collected, which are `required`, and — via +`visibleWhen` — when a field is even asked for. `resume` enforces all of it +server-side, so skipping the dialog and posting to the route directly is not a +way around what the author declared: + +``` +POST /api/v1/automation/{flow}/runs/{runId}/resume +{ "inputs": { "kind": "escalate" } } + +400 Invalid screen input: Screen field "escalation_reason" is required + — declared fields: 'kind', 'escalation_reason' +``` + +Two conditions are refused, both reported at once and both with +`code: 'INVALID_SCREEN_INPUT'`: + +- a **`required` field the caller was actually asked for** is missing (an empty + or blank string counts as missing); +- a key the screen **never declared** was sent. + +`visibleWhen` is evaluated against the **submitted values** first, so a hidden +field's `required` never fires — enforcing it would dead-end the run at a field +the user was never shown. A predicate that cannot be evaluated is treated as +hidden (and logged), because the client is the authority on what was rendered. + +Like the `$`-namespace rule above, the refusal happens **before** the suspension +is consumed: nothing is applied, the run stays parked, and the corrected +submission still lands. + +Three shapes declare no contract and so keep the pass-through — the same way an +action with no `params` is untouched: + +- an **object-form** screen (`config.objectName`), whose flat `fields` list is + empty by construction; the client persists the record through the normal write + path, which enforces that object's own `required` fields; +- a **message-only** screen (`waitForInput: true` with no fields); +- `signal.output`, which is the node-*output* namespace of the approval-style + resume envelope rather than the screen's collected-values channel. + Registering a pausing node of your own? Declare `resumeAuthority: 'service'` on its descriptor when the decision to continue belongs to your service rather than to whoever holds the run id. @@ -731,7 +790,68 @@ Edges connect nodes and define the execution path: | `type` | `enum` | optional | `'default'` (success), `'fault'` (error), `'conditional'` (expression-guarded), or `'back'` (declared back-edge, ADR-0044); defaults to `'default'` | | `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`) | | `label` | `string` | optional | Label displayed on the connector — cosmetic only. It does **not** select a path except on a branching node (`decision` / `approval`), which picks its out-edge by label. | -| `isDefault` | `boolean` | optional | BPMN default-flow marker (interop). Accepted by the schema, but **the engine does not read it** — traversal selects by `condition` and by `label`. On a `decision` node, the fallback is the out-edge labelled `default`: when no `conditions[]` entry matches, the node emits `branchLabel: 'default'` | +| `isDefault` | `boolean` | optional | BPMN default flow — the **"otherwise" branch**. Traversed only when no sibling `condition` on the same source node matched; never part of the unconditional parallel fan-out. Mutually exclusive with `condition`, at most one per node | + +### Branching — pick one mechanism per node + +A node has exactly two ways to split its path, and mixing them is what makes a +guard stop guarding (#4414). + +**Branch on the edges** (BPMN exclusive gateway — the default choice): + +```typescript +{ id: 'check', type: 'decision', label: 'Already converted?' }, // no config +// … +edges: [ + { id: 'e_yes', source: 'check', target: 'abort', condition: "lead.status == 'converted'", label: 'Yes' }, + { id: 'e_no', source: 'check', target: 'proceed', isDefault: true, label: 'No' }, +] +``` + +`e_no` runs **only** when `e_yes`'s condition was false. Drop `isDefault` and +`e_no` becomes an ordinary unconditional out-edge that runs on **every** pass — +in parallel with `abort` — so an already-converted lead sees the abort screen +*and* walks into the wizard behind it. Writing the negation of every sibling +condition by hand is the only other correct spelling; `isDefault` is the one +that stays correct when a third branch is added. + +**Branch on the node** (Salesforce-style decision outcomes): the node declares +`config.conditions[]` and traversal restricts itself to the out-edge whose +`label` matches the first matching entry. + +```typescript +{ id: 'check', type: 'decision', config: { conditions: [ + { label: 'Yes', expression: "lead.status == 'converted'" }, +] } }, +edges: [ + { id: 'e_yes', source: 'check', target: 'abort', label: 'Yes' }, + { id: 'e_no', source: 'check', target: 'proceed', isDefault: true }, +] +``` + +The labels must match **exactly**. When no declared condition matches, the node +reports the branch `default`, claimed by an out-edge labelled `'default'` or by +the `isDefault` edge. A branch label that no out-edge carries cannot route: the +engine logs a warning and falls back to considering every out-edge. That +fallback used to be silent, and a decision declaring `'Yes — already converted'` +against an out-edge labelled `'Yes'` is how #4414 shipped. `os validate` reports +the shape as `flow-branch-label-unmatched` at build time, along with +`flow-decision-unconditional-branch` (a guarded decision with an unconditional +sibling), `flow-default-edge-with-condition` and `flow-multiple-default-edges`. + + +A decision node that declares **no** `conditions` reports no branch at all — it +is a plain gateway and its out-edges do the routing. + +Declaring **both** — `config.conditions` *and* per-edge `condition`s — is +redundant but not wrong: the node picks a branch, and then that branch's edge +re-decides with the same predicate. The Studio flow designer emits exactly this +(it copies each branch's expression and label onto the edge it wires), and it +routes correctly because the two are kept in sync by construction. Hand-written +metadata has no such guarantee, which is the whole of #4414: when the two +disagree, the node's branch wins the narrowing and the edge's predicate decides +what actually runs. If you are writing the flow by hand, pick one. + ### Fault edges — handling a failed node @@ -918,30 +1038,48 @@ failures so one broken flow does not abort startup. ## Expressions in flows -A flow mixes **three expression dialects**, and using the wrong one is the -single most common way a flow silently misbehaves. Which dialect applies is -decided by *where* the expression sits — not by what it looks like: +A flow mixes **two expression dialects**, and the rule is short: **every +condition is CEL; braces are for values.** | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| | Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` | | Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above | -| Decision-node `conditions[].expression` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` | +| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` | | Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days) | -**The two failure modes to memorize:** +**The failure modes to memorize:** -1. **Braces missing in a decision expression** — `'order_amount > 10000'` isn't - evaluated as a variable at all. It compares the *string* `"order_amount"` - against `"10000"`, which is **always true**, so the flow always takes the - first branch and never tells you. Write `'{order_amount} > 10000'`. -2. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the +1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`. +2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions + fail loudly rather than silently, with an error that tells you to drop the + braces. + -The mirror mistake is putting braces *into* a CEL condition -(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently, -with an error that tells you to drop the braces. + +**Decision-node expressions used to be compared as text** (#4414, #4336), so +`'order_amount > 10000'` compared the string `"order_amount"` against `"10000"` +and was **always true**, while `'{lead_record.status} == "converted"'` was +**always false** — the brace form substitutes a whole flow variable by name, and +a field access on an object variable is not one. Both reported `success`. They +are bare CEL now, so the spellings in the table above are the correct ones and +`lead_record.status == 'converted'` resolves the field. + +The `{var}` form still works where a condition is a plain authored string — a +start node's `config.condition`: `{amount} > 100`, `{status} == 'active'`. The +two ways it used to answer `false` without saying so are now **loud errors** +naming the reference: a `{…}` hole that matches no flow variable, and a +substituted value that is neither a boolean, a number, nor part of a comparison +(#4336). + +**A decision's `conditions[].expression` is the exception — it is always CEL.** +The slot is declared bare CEL and is on the expression ledger as a predicate +(#4439), so a braced spelling there is not the `{var}` dialect but a build +failure: `os build` and `registerFlow` reject it, naming +`config.conditions[N].expression`. That is deliberate — the alternative is a +build that refuses what run time would happily execute. CEL conditions that fail to evaluate raise an error and stop the run — they diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5d8de57740..ff1f22191f 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -75,6 +75,56 @@ actually connect to Turso. > Knex client name (`pg` / `mysql2` / `better-sqlite3`) when you instantiate > `SqlDriver`. +## `config` is validated per driver + +A datasource's `config` is driver-specific — a SQLite `filename` and a Postgres +`host` share no shape — so the datasource schema keeps that slot open at the top +level and parses it against the contract for the driver you named. Each built-in +driver ships that contract as a zod schema, exported from `@objectstack/spec/data`: + +| `driver` | Contract | Keys | +| :--- | :--- | :--- | +| `postgres` \| `postgresql` \| `pg` | `PostgresConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `schema`, `applicationName`, `statementTimeout`, `autoMigrate` | +| `mysql` \| `mysql2` \| `mariadb` | `MysqlConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `autoMigrate` | +| `sqlite` \| `sqlite3` | `SqliteConfigSchema` | `filename`, `autoMigrate` | +| `sqlite-wasm` \| `wasm-sqlite` | `SqliteWasmConfigSchema` | `filename`, `persist` | +| `mongo` \| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` | +| `memory` \| `in-memory` | `MemoryConfigSchema` | `initialData`, `strictMode`, `persistence` | + +An unrecognised key is rejected with its correction, at authoring time and in the +Setup → Datasources wizard alike: + +```text +Unrecognized key(s) on this postgres datasource's config: `hostname`. +Did you mean `hostname` → `host`? +``` + +This matters more than a typical typo check, because the failure it replaces was +silent: a misspelled key was dropped, the driver fell back to its own defaults, +and the datasource connected to `localhost` while every signal — the parse, the +save, the connection probe — reported success. + +Two things live **outside** `config`, because they are not driver-specific: + +- **Pool sizing** — the `pool` block on the datasource (`min`, `max`, + `idleTimeoutMillis`, `connectionTimeoutMillis`), honoured for every SQL driver + and mapped onto the Mongo client's `minPoolSize` / `maxPoolSize`. +- **TLS certificates** — the `ssl` block on the datasource (`enabled`, + `rejectUnauthorized`, `ca`, `cert`, `key`). Inside `config`, `ssl` is the + on/off boolean shorthand. +- **`schemaMode`** — the ADR-0015 ownership mode, declared next to `driver`. + +A plugin-contributed driver (`com.vendor.snowflake`) has no contract in this +repo, so its `config` is left unvalidated rather than judged against a shape the +platform does not have. + + +The same schemas are projected to JSON Schema for +`DriverDefinitionSchema.configSchema` and for `GET /api/v1/datasources/drivers`, +which the Studio connection form renders — so the form offers exactly the fields +the validator accepts. + + ## Startup: a driver that cannot connect aborts the boot `ObjectQLEngine.init()` connects every registered driver during kernel diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index d2cdc4ae38..2c95e566ea 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -35,7 +35,7 @@ export const Warehouse = defineDatasource({ label: 'Analytics Warehouse (Postgres)', driver: 'postgres', schemaMode: 'external', // ObjectStack never runs DDL here - config: { host: 'db.internal', port: 5432, database: 'analytics', user: 'readonly' }, + config: { host: 'db.internal', port: 5432, database: 'analytics', username: 'readonly' }, external: { allowWrites: false, // read-only (the default) credentialsRef: 'sys_secret:9f2c…', // opaque handle minted by the secret store @@ -108,12 +108,22 @@ A declared datasource auto-connects when it is **meaningfully addressed**: 1. it is **external** (`schemaMode !== 'managed'`), **or** 2. an object **explicitly** binds to it via `object.datasource === `, **or** -3. it sets **`autoConnect: true`**. +3. it sets **`autoConnect: true`**, **or** +4. a **`datasourceMapping` rule routes at least one object to it**. -A `managed` datasource that nothing explicitly binds to (for example one that is -only referenced by a `datasourceMapping` rule) stays *metadata-only* — visible in -Setup, but not connected — so existing apps are unchanged. Use `autoConnect: true` -to opt such a datasource into a live connection at boot. +A `managed` datasource that nothing routes to stays *metadata-only* — visible in +Setup, but not connected. Use `autoConnect: true` to opt such a datasource into a +live connection at boot. + + +**A mapping rule is routing, not a hint.** If a `datasourceMapping` rule routes an +object to a datasource that cannot be connected, the boot **fails** with the +connect error, and a query against that object throws rather than resolving the +default store. Before v17 it fell through silently: the app booted clean, `/ready` +answered `200`, and the object's rows were written to the *default* database +instead of the one it declared. If you want a declared datasource that routes +nothing, remove the mapping rule rather than relying on the fall-through. + **Escape hatch.** An `onEnable` hook calling `ctx.drivers.register(driver)` is diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 875f6ba4b3..fcb936538e 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -181,6 +181,24 @@ order: Field.masterDetail('order', { | `inlineColumns` | `array` | Optional explicit columns for the inline grid | | `inlineAmountField` | `string` | Optional numeric child field for the inline running total | +#### Referential integrity + +`reference` is **enforced on write**. A create or update that sets a +`lookup` / `master_detail` to an id with no matching row in the target object is +rejected with `400 VALIDATION_FAILED` and a `fields[]` entry whose `code` is +`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). The +same check runs on bulk updates. + +Clearing a relationship is not a dangling reference: `null`, `""` and `[]` mean +"no link" — exactly what `deleteBehavior: 'set_null'` writes when the parent +goes away. + + +`deleteBehavior` governs what happens to *this* record when the **referenced** +record is deleted; the integrity check above governs what may be **written** +here in the first place. They are two halves of the same relationship contract. + + ### File & Media Types | Type | Factory | Description | diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index bee266d2ef..7f256cd5f9 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -256,7 +256,7 @@ backend chooses, exactly as before. ### Keyset Pagination — a `where` predicate on the sort key -`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): nothing on the server +`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): nothing on the server ever read it, so a cursor query silently returned the same first page every time. The key is tombstoned and `QueryBuilder.cursor()` was removed with it. Express the keyset directly — seek past the last row instead of offsetting: @@ -422,7 +422,7 @@ come later behind a driver capability flag without changing these semantics. ## Joins — removed -`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049 +`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049 enforce-or-remove): no driver's `find()` ever executed a join — the SQL, in-memory, and MongoDB drivers all ignored the array, so it only ever declared a capability that did not run. The key is tombstoned: authoring it is a `tsc` error, and a query carrying it @@ -532,7 +532,7 @@ by `@objectstack/plugin-pinyin-search`) recomputes the column on demand. ## Window Functions — removed from the request surface -`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): +`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286): `ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` route never routed it anywhere, so sending it had no effect. The key is tombstoned — a query carrying it fails to parse with the upgrade prescription — and the @@ -565,7 +565,7 @@ report/dashboard metadata. ### Distinct Records — removed flag, three live spellings -The top-level `query.distinct` flag was **removed in `@objectstack/spec` 18** (#4286): +The top-level `query.distinct` flag was **removed in `@objectstack/spec` 17** (#4286): no driver's `find()` ever applied it, and its only observable effect was mis-wired — it silently suppressed the REST list count while still returning duplicate rows (the count is truthful again). The key is tombstoned and `QueryBuilder.distinct()` was diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9f9be95817..eaf0a8f956 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -435,6 +435,14 @@ os validate path/to/config # Validate specific file `dataset` / `dimensions` / `values` resolves to a declared dataset/field, so a dangling binding fails here instead of rendering an empty chart. +…and every other author-time rule the three commands share — view shape, +name/action/filter references, page sources, approval approvers, security +posture, the autonumber and view-reference lints. All of them come from one +registry, so the list is the same on `os build` and `os lint`; see +[The one gate, three entry points](/docs/deployment/validating-metadata#the-one-gate-three-entry-points) +for the full matrix. Every failing rule is reported in a single run rather than +stopping at the first, so one pass shows the whole hole. + **Options:** - `--strict` — Treat warnings as errors (exit code 1) - `--json` — Output results as JSON @@ -444,13 +452,18 @@ os validate path/to/config # Validate specific file - Missing `manifest.namespace` (required for multi-app hosting) - No objects defined - No apps or plugins defined +- Every advisory the rule registry raised (dangling `stageField` / + `highlightFields` pointers, replay-unsafe seeds, ambiguous flow status, + deprecated visibility aliases, …) -`os validate` and `os build` share one validator, so a config that passes -`os validate` will not fail the build on schema/predicate/binding grounds. In a -scaffolded project these are wired as `npm run validate` and `npm run build`; -your `AGENTS.md` tells coding agents to run `npm run validate` after editing -metadata. See [Validating metadata](/docs/deployment/validating-metadata). +`os validate`, `os build` and `os lint` share one rule registry, so a config that +passes any of them will not fail another on schema/predicate/binding grounds — a +CLI test fails the build if a rule that can gate runs on fewer than all three +(#4409). In a scaffolded project these are wired as `npm run validate` and +`npm run build`; your `AGENTS.md` tells coding agents to run `npm run validate` +after editing metadata. See +[Validating metadata](/docs/deployment/validating-metadata). #### `os info` @@ -576,6 +589,7 @@ written. | `os migrate plan` | Warns and continues — a plan writes nothing either way | | `os migrate apply` | **Refuses** (exit 1, `error: database_busy` under `--json`). Stop the other process, or pass `--force` | | `os migrate files-to-references --apply` | **Refuses** likewise — it rewrites rows, so a concurrent writer is at least as dangerous | +| `os migrate meta --stored --apply` | **Refuses** likewise — it rewrites `sys_metadata` rows, and a live process saving metadata is exactly the collision | The check applies to SQLite only: Postgres and MySQL take their own server-side locks. Only same-user processes are visible without elevated privileges, and a @@ -629,6 +643,7 @@ where the data lives. |---------|-------------| | `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag | | `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean | +| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run | ```bash os migrate files-to-references # Dry run: full report, writes nothing @@ -761,6 +776,101 @@ closed gate logs that it is enforcing, and an app that declares neither class of field says nothing at all. So a running deployment always tells you the state of its own data — which is the question `os migrate meta` cannot answer. +#### `os migrate meta --stored` + +The two commands above are about **application data**. This one is about the +**metadata itself**, at rest: the `sys_metadata` rows Studio and the runtime +authoring APIs write. + +Those rows already *read* correctly whatever protocol they were written under — +every rehydration seam replays the full conversion chain, so a body from an +older major is served in today's canonical shape and always will be. What the +rows do not do is *change*: they keep their original bytes, the chain re-lowers +them on every load, and each one logs a conversion notice once per boot. This +command ends that for the deployment that runs it. + +```bash +os migrate meta --stored # Preview: per-row report, writes nothing +os migrate meta --stored --apply # Rewrite the rows (prompts) +os migrate meta --stored --apply --yes --json # CI / scripts +os migrate meta --stored --type view --type object # Restrict to a type (repeatable) +``` + +It walks `active` and `draft` rows across every organization (archived rows are +a record of what *was* and are never read), replays the same chain the read path +does, and re-saves each changed body through the normal write path — so a +rewritten row gets a `sys_metadata_history` entry, a fresh checksum, and the +mutation projectors, exactly like an author's save. The history entry's source +is `migrate-stored`, so a later diff shows which changes were an upgrade and +which were somebody's edit. + +Three things it deliberately declines, and names in the report rather than +counting as done: + +| Not rewritten | Why | +| :--- | :--- | +| Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path | +| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio | +| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip | + +**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an +open-namespace conflict guard that has to consult the *live* executor registry +to tell a rename from a clobber, so this run boots the automation engine — in an +inert mode that installs the node registry and then arms nothing: no flow +registered, no record trigger or scheduled job bound, no connector +materialized, no suspended run resumed. A migration process must not become a +second server. What gets written back for a flow is the conversion result plus +the `condition` envelopes the schema derives, and deliberately **not** the +schema's defaults (`version`, `runAs`, per-edge `type`) — persisting a default +the author never wrote would pin that row to today's value while untouched rows +follow tomorrow's, which is the drift this command exists to remove. + + +`--apply` is the only writing mode, and it rewrites **metadata** — each affected +row's checksum moves and each gets a history entry. Preview first. Like the +other row-rewriting migration, an apply run refuses to start while another +process holds the SQLite database (`--force` overrides). + + +**Nothing gates on this having run.** The read path is the guarantee, for every +deployment, whether or not anyone runs this — an operator-run migration is not +something the platform can depend on. What running it buys is hygiene (cleaner +diffs, exports and history from here on, and the recurring boot notices go +quiet) plus one thing that was previously unobtainable: **you can assert it.** +A run with nothing left to do exits `0`; a deployment with rows still carrying +an old dialect exits `1`. So "my metadata is on protocol N" becomes a check +rather than a belief. + +Note the division of labour with the default mode: `os migrate meta --from N` +rewrites an **author's source** and reads no database; `--stored` rewrites **one +deployment's rows** and reads no config. Same chain, opposite ends of the +contract — which is why the two modes are mutually exclusive. + +**Without shell access, use the route.** This command needs to reach the +deployment's database directly, which a hosted operator cannot do. The same pass +is exposed over HTTP: + +```http +POST /api/v1/meta/_migrate-stored +Content-Type: application/json + +{ "apply": true, "types": ["flow"] } +``` + +or from the SDK: + +```ts +const preview = await client.meta.migrateStored(); // writes nothing +const result = await client.meta.migrateStored({ apply: true }); +``` + +It returns the same report the CLI renders, and takes the same posture: +**preview unless `apply` is literally `true`**, `types` optional. It requires the +`manage_metadata` capability — it rewrites every eligible row in the deployment, +not one item — and answers `403` otherwise. Flows need no extra setup on this +path: the server already holds a live automation engine, so the run resolves the +executor registry the conflict guard needs from the process it is running in. + ### Scaffolding | Command | Alias | Description | @@ -819,21 +929,32 @@ os create example my-app # Create examples/my-app | Command | Description | |---------|-------------| -| `os lint [config]` | Check metadata for style and convention issues (beyond `validate`'s hard gates) | +| `os lint [config]` | Every author-time gate `validate`/`build` run, plus style and convention checks | | `os test [files]` | Run Quality Protocol test scenarios against a running server | | `os doctor` | Check development environment health | #### `os lint` -Style and convention checks on top of `os validate` — naming, labels, translation coverage — with a 0-100 quality score: +The cheapest of the three author-time commands. It runs the same rule registry +`os validate` and `os build` run — so anything that can fail a build fails here +too — and adds its own style rubric: naming, labels, namespace prefixes, +data-model conventions, translation coverage, with a 0-100 quality score. ```bash -os lint # Style / convention checks +os lint # Author-time rules + style / convention checks os lint --score # Append a 0-100 metadata quality score (letter-graded) os lint --fix # Show what would be fixed (dry-run) os lint --json # JSON output for CI ``` +It does not replace `os validate`: `os lint` never parses the stack against the +Zod schema (a schema error is `os validate`'s verdict to give), and it emits no +artifact. What it does guarantee is the direction that matters for a pre-flight +— a green `os lint` is not followed by a red `os build`. That was not true +before #4409: `os lint` ran one gating rule neither other command ran and missed +six that both of them ran, so it disagreed with the build in **both** +directions. + #### `os test` Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server. diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index 4ab68c7496..c73ada4e30 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -280,7 +280,7 @@ console.log(field.maxLength?.toString() ?? 'no limit'); 4. **Avoid deep nesting** — Limit nested `$and`/`$or` depth 5. **Use keyset pagination** — For large datasets, seeking past the last row is faster than a deep `offset`. Express the keyset as a `where` predicate on the - sort key (the `cursor` query property was removed in `@objectstack/spec` 18, + sort key (the `cursor` query property was removed in `@objectstack/spec` 17, #4286 — nothing ever read it) ```typescript diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 5cadf05f09..ffd26e6294 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -290,16 +290,36 @@ filter chip, or one form field short. Checked on every injected block: ``'s `fields`/`columns`/`sort`/`grouping`/`userFilters`, ``'s `fields`, `initialValues` keys, `sections[].fields[]` and `subforms` (each against its own -`childObject`), and `` / `` / `` / -`` — those last four through the **same** descriptor table -§5 uses, so a component's field-bearing props are described once and checked on -both surfaces. `` reaches that table by the type the author -writes, so the escape hatch is covered rather than left as a hole. +`childObject`). `` reaches the §5 descriptor table by the type +the author writes, so the escape hatch is covered rather than left as a hole. -`` is the **related (child)** object whose records -are listed — the parent record is bound by `recordId`, and `relationshipField` -is the child's field pointing back at it. Passing the parent there is the -mistake this check was extended to catch. +### 10b. A `record:*` block on a react page + +The `record:*` family — ``, ``, +``, ``, and the rest — renders from the record +context a **record page** mounts once for the record it routed to. A +`kind:'react'` page mounts no such context, so these blocks render empty +whatever props they are given; the react contract published `objectName` / +`recordId` for four of them and no renderer ever read either. + +```jsx + +// ↑ error: renders empty here — those props are not read +``` + +They are withdrawn from the react tier, and using one is an **error** +(`react-block-needs-record-context`) — by tag, and through +`` alike. On a react page the parent record is ordinary +React state, so bind it with a block that reads its own props: `', '=', parentId]}>` for a related list, +`` for a field panel. To use the family +itself, author the page as `type:'record'`. + +On a **record page**, where these blocks do work, §5 checks their field-bearing +props, and `` is the **related (child)** object +whose records are listed — the parent record comes from the page, and +`relationshipField` is the child's field pointing back at it. Passing the parent +there is the mistake that check was extended to catch. A **filter position** is the exception that gates: @@ -318,51 +338,67 @@ Skipped, to keep false positives at zero: the same set as §8 — non-static values, `{...spread}` usages, relationship paths, system fields, and objects another package defines. -## The one gate, two entry points - -`os validate` and `os build` (alias of `os compile`) run the **same** validator: - -| | `os validate` | `os build` | -|---|---|---| -| Protocol schema (Zod) | ✓ | ✓ | -| CEL / predicate validation | ✓ | ✓ | -| Widget-binding integrity | ✓ | ✓ | -| Dashboard action/route references (ADR-0049) | ✓ | ✓ | -| Object & action name references (#3583) | ✓ | ✓ | -| Page-component field bindings (#3583) | ✓ | ✓ | -| React page block field bindings — §10 (#4340) | ✓ | ✓ | -| Chart bindings outside dashboards (#3583) | ✓ | ✓ | -| Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ | -| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | -| Autonumber `{field}` interpolation | ✓ | ✓ | -| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | -| Flow authoring anti-patterns (#1874) | ✓ | ✓ | -| Liveness author-warnings | ✓ | ✓ | -| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | -| Emits `dist/objectstack.json` | — | ✓ | - -So `os validate` is the fast inner-loop check (no artifact); `os build` is what -you run when you need the deployable artifact. A config that passes `os validate` -will not fail `os build` on schema/predicate/binding grounds — a test in the CLI -asserts that every gate `os build` runs is also run by `os validate`, so the two -cannot drift apart again (#3782). Both entry points -also check SDUI styling (ADR-0065), and `os validate` additionally runs a set of -view- and page-SHAPE checks — list-view navigation modes (ADR-0053), view -container shape, and whether a JSX/React page source parses at all -(ADR-0080/0081) — that catch UI metadata which would otherwise be silently -dropped. - -The field bindings INSIDE a react page source are a different matter: they are -reference-integrity, so they run wherever the suite runs. `os lint` gets them -too — it shares the same `REFERENCE_INTEGRITY_RULES` list, which is why the -table's reference rows are the ones a cheap pre-flight can rely on. That was not -always true: the react-page prop gate was hand-wired into `os validate` alone -until #4340's follow-up, so `os lint` and `os build` accepted a page whose every -field binding was stale — the same divergence #4394 closed for readonly flow -writes. A CLI test now asserts no command reaches for a suite member directly -(#4384). - -A clean run walks each gate and reports timing: +## The one gate, three entry points + +`os validate`, `os build` (alias of `os compile`) and `os lint` run the **same** +author-time rules, from one table — `AUTHORING_RULES` in +`packages/cli/src/lint/authoring-rules.ts`: + +| | `os validate` | `os build` | `os lint` | +|---|---|---|---| +| Protocol schema (Zod) | ✓ | ✓ | — | +| CEL / predicate validation (ADR-0032) | ✓ | ✓ | ✓ | +| List-view navigation modes (ADR-0053) | ✓ | ✓ | ✓ | +| View container shape | ✓ | ✓ | ✓ | +| Widget-binding integrity (ADR-0021) | ✓ | ✓ | ✓ | +| Dashboard action/route references (ADR-0049) | ✓ | ✓ | ✓ | +| Filter placeholder resolvability (#3574) | ✓ | ✓ | ✓ | +| Object & action name references (#3583) | ✓ | ✓ | ✓ | +| Page-component field bindings (#3583) | ✓ | ✓ | ✓ | +| React page block field bindings — §10 (#4340) | ✓ | ✓ | ✓ | +| Chart bindings outside dashboards (#3583) | ✓ | ✓ | ✓ | +| Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ | ✓ | +| SDUI scoped styling (ADR-0065) | ✓ | ✓ | ✓ | +| JSX / React page source parses (ADR-0080/0081) | ✓ | ✓ | ✓ | +| Approval-node approvers (ADR-0090 D3) | ✓ | ✓ | ✓ | +| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | ✓ | +| Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | +| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | +| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | +| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | +| Advisory: flow trigger wiring, record titles, semantic field pointers (ADR-0085), seed replay/state safety, capability references, liveness, visibility aliases | ✓ | ✓ | ✓ | +| Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | +| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | +| Naming, labels, data-model conventions, i18n coverage | — | — | ✓ | +| Emits `dist/objectstack.json` | — | ✓ | — | + +So `os validate` is the fast inner-loop check (no artifact), `os build` is what +you run when you need the deployable artifact, and `os lint` adds its own style +rubric on top. **Any rule that can fail a build runs on all three**, so a green +`os lint` means the build's gates are green too, and a stack cannot be published +through the one command that happens to skip a check. + +Two rows are deliberately not universal, and both are one-directional (neither +lets a stack through a gate another command enforces): the Zod parse and the +undeclared-key diff need the pre-parse tier and the schema, which only the two +commands that parse actually have; and `os lint`'s own rubric — snake_case +names, missing labels, data-model conventions — is a lint verdict, not a publish +gate. `os build` has never rejected a camelCase object name. + +That invariant is enforced, not merely documented. Each rule declares its command +coverage as data, and a CLI test fails if a rule that can emit `error` runs on +fewer than all three, if a narrowed rule carries no written reason, or if any +command reaches for a rule directly instead of going through the registry. + +The enforcement exists because the contract drifted four separate times, and the +last audit (#4409) found 23 of 26 rules running on some strict subset of the +three — nine of them able to fail a build. The worst direction was the least +obvious: **`os build` was the weakest of the three gates**, so it emitted an +artifact for stacks the other two refuse. A flow whose expression approver did +not parse built and published green; only `os lint` stopped it, and CI usually +runs the other two. + +A clean run walks the registry and reports timing: ``` ◆ Validate @@ -371,19 +407,9 @@ A clean run walks each gate and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Validating expressions (ADR-0032)... - → Checking list-view navigation modes (ADR-0053)... - → Checking view container shape... - → Checking dashboard widget bindings (ADR-0021)... - → Checking dashboard action references (ADR-0049)... - → Checking SDUI styling (ADR-0065)... - → Checking JSX-source pages (ADR-0080)... - → Checking React-source pages (ADR-0081)... - → Checking source-page styling (ADR-0065)... - → Checking capability references (ADR-0066)... - → Checking flow trigger wiring... - → Running authoring lints (#3782)... - → Checking security posture (ADR-0090 D7)... + → Running author-time rules (26)... + → Checking capability providers (#3366)... + → Checking package docs (ADR-0046)... ✓ Validation passed (64ms) @@ -397,9 +423,11 @@ see [the gate in action](/docs/getting-started/build-with-claude-code#4-the-gate for the bare-reference example verbatim. -`os lint` is a **separate** pass — style and convention checks (snake_case -naming, required labels, namespace prefixes, data-model patterns). Run it too, -but it does not replace `os validate`, and `os validate` does not replace it. +`os lint` runs every gate above **plus** its own style rubric (snake_case +naming, required labels, namespace prefixes, data-model patterns, translation +coverage). It does not replace `os validate` — it never parses against the Zod +schema, so a schema error is `os validate`'s verdict to give — but a rule that +can fail the build fails `os lint` too. ## The workflow diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 6600eb94a7..de9833bb52 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -157,7 +157,6 @@ Flows, state machines, approvals, and integrations. | **[State Machine](/docs/references/automation/state-machine)** | `state-machine.zod.ts` | StateMachine | State machine definitions | | **[Webhook](/docs/references/automation/webhook)** | `webhook.zod.ts` | Webhook | Outbound webhooks | | **[ETL](/docs/references/automation/etl)** | `etl.zod.ts` | ETLPipeline | Data transformation pipelines | -| **[Trigger Registry](/docs/references/automation/trigger-registry)** | `trigger-registry.zod.ts` | TriggerRegistry | Event-driven triggers | | **[Sync](/docs/references/automation/sync)** | `sync.zod.ts` | DataSyncConfig, SyncMode | Bi-directional data sync | ## Security Protocol (3 schemas) @@ -193,19 +192,18 @@ Environments, marketplace, licensing, and multi-tenancy. | **[Plugin Security](/docs/references/cloud/plugin-security)** | `plugin-security.zod.ts` | PluginSecurityProtocol, SBOM | Plugin security policies | | **[Tenant](/docs/references/cloud/tenant)** | `tenant.zod.ts` | Tenant | Multi-tenancy isolation | -## Integration Protocol (7 schemas) +## Integration Protocol (1 schema) -External system connectors and adapters. +External system connectors — one protocol (ADR-0097): a connector entry is +either a catalog descriptor or a provider-bound instance that a generic +executor (connector-openapi / connector-mcp) materializes at boot. The +per-provider schema "templates" (SaaS / database / file-storage / +message-queue / GitHub / Vercel) were removed in #4480: provider shapes come +from the provider itself, not from hand-written spec files. | Protocol | Source File | Key Schemas | Purpose | |:---------|:-----------|:------------|:--------| -| **[Connector](/docs/references/integration/connector)** | `connector.zod.ts` | Connector | Generic connector interface | -| **[SaaS Connector](/docs/references/integration/connector)** | `connector/saas.zod.ts` | SaaSConnector | SaaS integrations | -| **[Database Connector](/docs/references/integration/connector)** | `connector/database.zod.ts` | DatabaseConnector | Database adapters | -| **[File Storage](/docs/references/integration/connector)** | `connector/file-storage.zod.ts` | FileStorageConnector | Cloud storage | -| **[Message Queue](/docs/references/integration/message-queue)** | `connector/message-queue.zod.ts` | MessageQueueConnector | Queue integrations | -| **[GitHub](/docs/references/integration/connector)** | `connector/github.zod.ts` | GitHubConnector | GitHub API integration | -| **[Vercel](/docs/references/integration/connector)** | `connector/vercel.zod.ts` | VercelConnector | Vercel deployment | +| **[Connector](/docs/references/integration/connector)** | `connector.zod.ts` | Connector | The connector protocol — auth, sync, webhooks, rate limiting | ## Shared Protocol (5 schemas) diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index cf8fff765f..038afe0e02 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -21,6 +21,7 @@ The canonical `IDataEngine` interface uses **QueryAST-aligned parameter names** ```typescript import type { + BaseEngineOptions, EngineQueryOptions, DataEngineInsertOptions, EngineUpdateOptions, @@ -31,11 +32,12 @@ import type { } from '@objectstack/spec/data'; export interface IDataEngine { - // Query - find(objectName: string, query?: EngineQueryOptions): Promise; - findOne(objectName: string, query?: EngineQueryOptions): Promise; - count(objectName: string, query?: EngineCountOptions): Promise; - aggregate(objectName: string, query: EngineAggregateOptions): Promise; + // Query (reads take the execution context in a TRAILING options argument — + // the same position the write methods take theirs) + find(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; + findOne(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; + count(objectName: string, query?: EngineCountOptions, options?: BaseEngineOptions): Promise; + aggregate(objectName: string, query: EngineAggregateOptions, options?: BaseEngineOptions): Promise; // Mutation (write ops also accept in-process WriteObservabilityOptions — see `update`) insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise; @@ -65,6 +67,20 @@ export interface IDataEngine { All query methods use canonical **QueryAST parameter names**: `where`, `fields`, `orderBy`, `limit`, `offset`, `expand`. + +**Reads take the execution context in the trailing `options` argument**, the same +position the write methods take theirs — `find`, `findOne`, `count` and `aggregate` +all accept `options?: BaseEngineOptions`. + +This matters because the mistake it prevents is silent. The same `{ context }` object +is correct as the third argument to `insert`, and passing it as the third argument to +`find` used to be **dropped without error** — so an intended `isSystem` bypass simply +vanished, and control-plane reads started coming back empty once org-scoping hooks +landed (#4251). + +`query.context` remains supported. When **both** are given, `options.context` wins. + + ### find Executes a structured query with filtering, sorting, pagination, and field selection. Returns an array of records. @@ -146,7 +162,7 @@ where: { ### findOne -Convenience method that returns the first record matching a query, or `null`. +Returns the ONE record the query selects, or `null`. ```typescript const task = await engine.findOne('task', { @@ -155,6 +171,24 @@ const task = await engine.findOne('task', { }); ``` +The query must say **which** record it wants — a `where` (or a `search` that +expands to one), or an `orderBy` meaning "the first record in this order". A +query with neither is rejected (#4419): + +```typescript +await engine.findOne('task', {}); // throws +await engine.findOne('task', { // the newest task + orderBy: [{ field: 'created_at', order: 'desc' }], +}); +await engine.find('task', { limit: 1 }); // any task will do +``` + +`findOne` reads a single row, so a missing predicate does not come back as +`null` — it comes back as the object's **first row**, a real record unrelated to +the request that no `if (!task)` check can catch. No ordering is imposed when +you supply none: `findOne` promises *a* matching record, never a position in a +sequence. + ### count Returns the number of records matching a filter without fetching data. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 67f18463a2..763f88e664 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -24,12 +24,14 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core - ✅ Implemented — the 17 kernel-provided protocol methods (`DataProtocol` 9 + `MetadataProtocol` 8) - ⚠️ Framework — the slot is filled by the kernel's in-memory fallback: real reads and writes, no persistence (self-declares `degraded`, ADR-0076 D12) -- ❌ Plugin Required — the remaining 36 methods declared across the other per-domain +- ❌ Plugin Required — the remaining 33 methods declared across the other per-domain contracts (`analytics` 2, `automation` 1, `packages` 6, `views` 5, `permissions` 3, - `workflow` 3, `realtime` 6, `notification` 7, `i18n` 3). *Declared* is not *routed*: + `realtime` 6, `notification` 7, `i18n` 3). *Declared* is not *routed*: `packages` is answered kernel-side by the `/packages` dispatcher domain over the - ObjectQL registry, `i18n` has a kernel in-memory fallback, and `views` / `permissions` / - `workflow` have no implementation anywhere — the rest wait on whatever fills the slot + ObjectQL registry, `i18n` has a kernel in-memory fallback, and `views` / + `permissions` have no implementation anywhere — the rest wait on whatever fills + the slot. (`workflow`'s 3 methods were the fourth such group; the whole slot + retired in v17, [#4451](https://github.com/objectstack-ai/objectstack/issues/4451).) --- @@ -90,10 +92,12 @@ installable optional package, so they need no remedy (`NO_REMEDY_SLOTS` in the guard script). And `null` there does **not** always mean "nothing ships" — it means "no name belongs in an `Install X` sentence", which covers two cases: -- **Nothing provides the slot at all** — `search`, `workflow` (and the retired - `graphql`). Discovery says exactly that rather than naming a plausible - package: `No implementation ships for the '' slot — register a service - under it to enable`. +- **Nothing provides the slot at all** — `search`. Discovery says exactly that + rather than naming a plausible package: `No implementation ships for the + '' slot — register a service under it to enable`. (`workflow` and the + never-real `graphql` sat here too until both were retired outright in v17, + [#4451](https://github.com/objectstack-ai/objectstack/issues/4451) — a slot + nothing fills and nothing consumes is better removed than explained.) - **A provider exists but cannot be installed** — `ai`. `@objectstack/service-ai` registers the slot in `objectstack-ai/cloud` and is `private: true`. @@ -333,13 +337,18 @@ the domain answers **501** with that remedy spelled out, not a generic "install plugin". -### 6. workflow Service — 3 methods ❌ Nothing ships -`getWorkflowConfig`, `getWorkflowState`, `workflowTransition` -State machine transitions. No package registers the `workflow` slot -(`CORE_SERVICE_PROVIDER.workflow` is `null`). Approve/reject are not workflow -methods — per ADR-0019 they moved to the request-id-based approvals API under -`/api/v1/approvals` (`POST /requests/:id/{approve,reject,recall}`, served by -`@objectstack/plugin-approvals`). +### 6. workflow Service — retired in v17 +The slot, its `IWorkflowService` contract and the three `WorkflowProtocol` +methods (`getWorkflowConfig`, `getWorkflowState`, `workflowTransition`) were +removed in [#4451](https://github.com/objectstack-ai/objectstack/issues/4451): +nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method +ever had an implementation, and no host ever mounted `/api/v1/workflow`. The +three capabilities it named are live elsewhere — state-machine transitions are +an object validation rule of type `state_machine`, approvals are `approval` +flow nodes on the approvals runtime (ADR-0019 — decisions via +`POST /api/v1/approvals/requests/:id/{approve,reject,recall}`, served by +`@objectstack/plugin-approvals`), and record-triggered automation is lifecycle +hooks + `record_change` flows. ### 7. automation Service — 1 method ✅ `@objectstack/service-automation` `triggerAutomation` @@ -502,11 +511,16 @@ a package that cannot be installed is a dead end, which is why | Slot | State | |:-------|:------------| | **ui** | Nothing registers the slot. `ViewProtocol`'s five methods are declared and unrouted; view CRUD runs through `/api/v1/meta`, and `/api/v1/ui/view/:object` is served by the `protocol` service. | -| **workflow** | Nothing ships. `WorkflowProtocol`'s three methods have no implementation and no consumer. | | **search** | Nothing ships. Contract and engine enum exist in `@objectstack/spec` only. | | **ai** | Nothing in this repo — `service-ai` (chat, completion, models, conversations) is Cloud/EE. | | **realtime transport** | The service exists but no WebSocket/SSE route is mounted, so `routes.realtime` is deliberately never advertised. | +The `workflow` slot used to sit in this table ("nothing ships, no consumer"). +It was retired outright in v17 (#4451, per ADR-0115 Evidence 5): the +capability lives in `state_machine` validation rules, approval flow nodes +(ADR-0019) and `record_change` flows, so there is nothing left for a slot to +promise. + --- ## Plugin Implementation Pattern diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index 5b37b96834..a7e88796a2 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -161,7 +161,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative -**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks. +**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Criteria rules are re-evaluated on record insert/update via internal sharing rule hooks, on every write to the rule itself, and once per boot — so deactivating or deleting a rule withdraws the grants it materialized (see [Sharing Rules](/docs/permissions/sharing-rules#switching-a-rule-off-withdraws-the-access-it-granted)). ### Configuration Example diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index b0eb488cf9..cfdf7c9ebb 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -161,6 +161,38 @@ covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot seeding, lifecycle hooks, and backfills run as system context and are unaffected. +### Switching a rule off withdraws the access it granted + +A sharing rule's grants are **materialized** — evaluating a rule writes real +`sys_record_share` rows with `source: 'rule'` and `source_id` set to the rule. +Because those rows outlive the evaluation that produced them, withdrawal has to +be an explicit act, and it happens at **three** moments: + +| When | What is reconciled | +|:--|:--| +| **The write that deactivates or edits the rule** | That rule's grants, immediately — deactivating with `active: false` (or `POST {basePath}/sharing/rules` with the same name) revokes them before the call returns | +| **The next insert/update of a matching record** | That record's grants for every rule on the object — an inactive rule desires nothing, so its rows are revoked | +| **Every boot** | All rules, plus a sweep of `source: 'rule'` rows whose `source_id` no longer resolves to any rule | + +Deleting a rule withdraws its grants too, whether you delete it through +`DELETE {basePath}/sharing/rules/:idOrName` (by id **or** by name) or through +the plain data API in Setup. + +The practical guarantee: **an over-granting rule is always recoverable from the +API surface.** Switch it off or delete it, and the access it materialized is +gone — not on the next time somebody happens to touch the record, and not only +after a restart (objectstack#4433, #4434). A grant whose rule row has vanished +entirely is retired by the boot sweep, so a database repaired by hand — or +upgraded from a build that leaked these rows — converges on the next start. + + + Because withdrawal is materialized rather than computed at read time, the + revocation is visible in `sys_record_share` itself. `GET + {basePath}/data/:object/:id/shares` is the fastest way to confirm a rule's + access is really gone, and `POST {basePath}/sharing/rules/:idOrName/evaluate` + forces a reconcile on demand. + + ### There is no "share every record" rule The predicate is **mandatory on every authoring path**, whether you declare diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index c1be47021a..fbcaf5dcf8 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -47,14 +47,13 @@ GET /api/v1/discovery HTTP/1.1 "packages": "/api/v1/packages", "auth": "/api/v1/auth", "ui": "/api/v1/ui", - "storage": "/api/v1/storage", - "graphql": "/api/v1/graphql" + "storage": "/api/v1/storage" }, "services": { "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" }, - "workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" }, + "search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" }, "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" } }, "locale": { diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 64d8d9ba91..1dd4344cf5 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -95,7 +95,7 @@ on the `find()` path: `top` is the exception that *is* honored — the engine normalises it to `limit`. The #4286 sweep (ADR-0049 enforce-or-remove) settled every other declared-but-inert -member. **Removed** — tombstoned in `@objectstack/spec` 18, so a query carrying one +member. **Removed** — tombstoned in `@objectstack/spec` 17, so a query carrying one fails to parse with the upgrade prescription and authoring it is a `tsc` error: `joins` (related records are read through `expand`), `windowFunctions` (a SQL-driver door remains: `SqlDriver.findWithWindowFunctions()`), `cursor` (express the keyset as @@ -723,7 +723,7 @@ the driver's raw rows. ### Distinct -`query.distinct` was **removed in `@objectstack/spec` 18** (#4286): no driver ever +`query.distinct` was **removed in `@objectstack/spec` 17** (#4286): no driver ever rendered `SELECT DISTINCT`, and the flag's only observable effect was mis-wired — it silently suppressed the REST list count (`total`/`hasMore` degraded to a page-local estimate) while still returning duplicate rows. The key is tombstoned and @@ -793,7 +793,7 @@ expansion ignores them. ### Joins — removed (#4286) -`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049 +`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049 enforce-or-remove): no driver ever read it, so a query carrying `joins` silently ran as a single-table query. The key is tombstoned — authoring it is a `tsc` error, and a query that still carries it (even as an empty array) fails to parse with the upgrade @@ -805,7 +805,7 @@ joined in application code. ### Window Functions — removed from the request surface (#4286) -`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): `find()` +`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286): `find()` never applied it, so every OVER clause it declared was silently dropped. The key is tombstoned, and the `WindowFunction` / `WindowSpec` / `WindowFunctionNode` exports left with it — they declared `field` / `over` / `frame` members that no executor ever @@ -871,7 +871,7 @@ every page full and every row real (objectui#3106, #4363). A query with no ### Keyset Pagination -`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): no driver ever +`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): no driver ever implemented keyset pagination, so a cursor was accepted and ignored and every page came back identical — a caller looping "until `hasMore` is false" never terminated. The key is tombstoned (on `EngineQueryOptions` too) and `QueryBuilder.cursor()` was removed diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index f222a19089..255bfdd664 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -45,7 +45,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ cubes: { name: string; title?: string; description?: string; sql: string; … }[] }` | ✅ | | @@ -80,7 +80,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: { name: string; type: string }[]; sql?: string }` | ✅ | | @@ -94,7 +94,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index 5e99727a21..494d235919 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -102,7 +102,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -138,7 +138,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 579bf2b348..6dc9ee1a95 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -129,7 +129,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -154,7 +154,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -197,7 +197,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -223,7 +223,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>; … }` | ✅ | Full execution log with step details | @@ -251,7 +251,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: { name: string; label: string; type: string; status: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -279,7 +279,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: { id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -305,7 +305,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -335,7 +335,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -361,7 +361,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 481fc45f0b..ce6565d201 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -60,7 +60,7 @@ const result = BatchConfig.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -127,13 +127,13 @@ const result = BatchConfig.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | | **failed** | `number` | ✅ | Number of records that failed | -| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | +| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | --- diff --git a/content/docs/references/api/connector.mdx b/content/docs/references/api/connector.mdx index 13d4911417..aac0b373f2 100644 --- a/content/docs/references/api/connector.mdx +++ b/content/docs/references/api/connector.mdx @@ -5,10 +5,6 @@ description: Connector protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/connector.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 1bd4433ffa..666da14410 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -35,7 +35,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ ERROR_CODE_LEDGER) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ ERROR_CODE_LEDGER) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | @@ -52,7 +52,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -91,9 +91,9 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; index?: number; … }[]` | ✅ | Results for each item in the batch | +| **data** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; index?: number; … }[]` | ✅ | Results for each item in the batch | --- @@ -133,7 +133,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -185,7 +185,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -201,7 +201,7 @@ const result = ApiError.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -234,7 +234,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/core-services.mdx b/content/docs/references/api/core-services.mdx index 1583948a79..5f88cac2aa 100644 --- a/content/docs/references/api/core-services.mdx +++ b/content/docs/references/api/core-services.mdx @@ -5,10 +5,6 @@ description: Core Services protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/core-services.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index 3b1d36b48b..f38ebe4b81 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -52,7 +52,6 @@ const result = ApiRoutes.parse(data); | **storage** | `string` | optional | e.g. /api/v1/storage | | **analytics** | `string` | optional | e.g. /api/v1/analytics | | **packages** | `string` | optional | e.g. /api/v1/packages | -| **workflow** | `string` | optional | e.g. /api/v1/workflow | | **approvals** | `string` | optional | e.g. /api/v1/approvals | | **realtime** | `string` | optional | e.g. /api/v1/realtime | | **notifications** | `string` | optional | e.g. /api/v1/notifications | diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx index 18d82d1b95..8cde2909a2 100644 --- a/content/docs/references/api/dispatcher.mdx +++ b/content/docs/references/api/dispatcher.mdx @@ -51,7 +51,7 @@ const result = DispatcherConfig.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | +| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | | **fallback** | `Enum<'404' \| 'proxy' \| 'custom'>` | ✅ | Behavior when no route matches | | **proxyTarget** | `string` | optional | Proxy target URL when fallback is "proxy" | @@ -91,7 +91,7 @@ Route-resolution failure mode emitted in `error.code` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **prefix** | `string` | ✅ | URL path prefix for routing (e.g. /api/v1/data) | -| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | Target core service name | +| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | Target core service name | | **authRequired** | `boolean` | ✅ | Whether authentication is required | | **criticality** | `Enum<'required' \| 'core' \| 'optional'>` | ✅ | Service criticality level for unavailability handling | | **permissions** | `string[]` | optional | Required permissions for this route namespace | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 7e61d6deca..cea67bace3 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -280,6 +280,9 @@ const result = ErrorCode.parse(data); * `REQUEST_NOT_FOUND` * `RESEED_NO_ROWS` * `RESEED_SKIPPED` +* `RESUME_FAILED` +* `RESUME_IN_PROGRESS` +* `RESUME_TARGET_LOST` * `ROUTE_NOT_FOUND` * `RULE_DEFINE_FAILED` * `RULE_DELETE_FAILED` @@ -287,6 +290,7 @@ const result = ErrorCode.parse(data); * `RULE_GET_FAILED` * `RULE_LIST_FAILED` * `RULE_NOT_FOUND` +* `RUN_NOT_FOUND` * `SAML_REGISTER_FAILED` * `SCHEDULES_LIST_FAILED` * `SCHEDULE_DELETE_FAILED` @@ -303,6 +307,7 @@ const result = ErrorCode.parse(data); * `SIGN_IN_REQUIRED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` +* `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` * `SUGGESTION_DISMISS_FAILED` * `SUGGESTION_LIST_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 75fab61fd8..c337ca0264 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -59,7 +59,7 @@ const result = CreateExportJobRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -159,7 +159,7 @@ const result = CreateExportJobRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -233,7 +233,7 @@ const result = CreateExportJobRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -451,7 +451,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -490,7 +490,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: { jobId: string; object: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; … }[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -548,7 +548,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/http.mdx b/content/docs/references/api/http.mdx index bbbd7be6ef..6eb5ac2296 100644 --- a/content/docs/references/api/http.mdx +++ b/content/docs/references/api/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/identity.mdx b/content/docs/references/api/identity.mdx index f57cb9f9af..042270686d 100644 --- a/content/docs/references/api/identity.mdx +++ b/content/docs/references/api/identity.mdx @@ -5,10 +5,6 @@ description: Identity protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/identity.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/metadata-plugin.mdx b/content/docs/references/api/metadata-plugin.mdx index dc3fa4e956..75edc8ead2 100644 --- a/content/docs/references/api/metadata-plugin.mdx +++ b/content/docs/references/api/metadata-plugin.mdx @@ -5,10 +5,6 @@ description: Metadata Plugin protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/metadata-plugin.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index bc140013ca..df19cac6f4 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -66,7 +66,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; version?: any; description?: string; … }` | ✅ | Full App Configuration | @@ -80,7 +80,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -94,7 +94,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: { type: string; name: string; error: string }[] }` | ✅ | Bulk operation result | @@ -119,7 +119,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -133,7 +133,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -147,7 +147,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -161,7 +161,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -175,7 +175,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -202,7 +202,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -230,7 +230,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -244,7 +244,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -258,7 +258,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -272,7 +272,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -286,7 +286,7 @@ const result = AppDefinitionResponse.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -350,7 +350,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: { type: string; name: string; namespace?: string; label?: string; … }[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -378,7 +378,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -392,7 +392,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -418,7 +418,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: { path: string; message: string; code?: string }[]; warnings?: { path: string; message: string }[] }` | ✅ | Validation result | @@ -432,7 +432,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/notification.mdx b/content/docs/references/api/notification.mdx index 063d3d6f18..d792d419f8 100644 --- a/content/docs/references/api/notification.mdx +++ b/content/docs/references/api/notification.mdx @@ -5,10 +5,6 @@ description: Notification protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/notification.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index e04c82b3a7..fd09fe328e 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -65,7 +65,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -97,7 +97,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: { manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -151,7 +151,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: { type: 'namespace_conflict'; requestedNamespace: string; conflictingPackageId: string; conflictingPackageName: string; … }[]; message?: string }` | ✅ | | @@ -193,7 +193,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -228,7 +228,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -258,7 +258,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: { packageId: string; requiredRange: string; resolvedVersion?: string; installedVersion?: string; … }[]; canProceed: boolean; requiredActions: { type: Enum<'install' \| 'upgrade' \| 'confirm_conflict'>; packageId: string; description: string }[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -285,7 +285,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -317,7 +317,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/package-registry.mdx b/content/docs/references/api/package-registry.mdx index e48585dbdc..52f810ab06 100644 --- a/content/docs/references/api/package-registry.mdx +++ b/content/docs/references/api/package-registry.mdx @@ -5,10 +5,6 @@ description: Package Registry protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/package-registry.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx index f8c0b07a4a..78a8600c9e 100644 --- a/content/docs/references/api/plugin-rest-api.mdx +++ b/content/docs/references/api/plugin-rest-api.mdx @@ -227,7 +227,7 @@ const result = ErrorHandlingConfig.parse(data); | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method for this endpoint | | **path** | `string` | ✅ | URL path pattern (e.g., /api/v1/data/:object/:id) | | **handler** | `string` | ✅ | Protocol method name or handler identifier | -| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category | +| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category | | **public** | `boolean` | ✅ | Is publicly accessible without authentication | | **permissions** | `string[]` | optional | Required permissions (e.g., ["data.read", "object.account.read"]) | | **summary** | `string` | optional | Short description for OpenAPI | @@ -253,7 +253,7 @@ const result = ErrorHandlingConfig.parse(data); | **enabled** | `boolean` | ✅ | Enable REST API plugin | | **basePath** | `string` | ✅ | Base path for all API routes | | **version** | `string` | ✅ | API version identifier | -| **routes** | `{ prefix: string; service: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; methods?: string[]; … }[]` | ✅ | Route registrations | +| **routes** | `{ prefix: string; service: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; methods?: string[]; … }[]` | ✅ | Route registrations | | **validation** | `{ enabled: boolean; mode: Enum<'strict' \| 'permissive' \| 'strip'>; validateBody: boolean; validateQuery: boolean; … }` | optional | Request validation configuration | | **responseEnvelope** | `{ enabled: boolean; includeMetadata: boolean; includeTimestamp: boolean; includeRequestId: boolean; … }` | optional | Response envelope configuration | | **errorHandling** | `{ enabled: boolean; includeStackTrace: boolean; logErrors: boolean; exposeInternalErrors: boolean; … }` | optional | Error handling configuration | @@ -276,7 +276,6 @@ const result = ErrorHandlingConfig.parse(data); * `permission` * `analytics` * `automation` -* `workflow` * `ui` * `realtime` * `notification` @@ -294,9 +293,9 @@ const result = ErrorHandlingConfig.parse(data); | :--- | :--- | :--- | :--- | | **prefix** | `string` | ✅ | URL path prefix for this route group | | **service** | `string` | ✅ | Core service name (metadata, data, auth, etc.) | -| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Primary category for this route group | +| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Primary category for this route group | | **methods** | `string[]` | optional | Protocol method names implemented | -| **endpoints** | `{ method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; path: string; handler: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; … }[]` | optional | Endpoint definitions | +| **endpoints** | `{ method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; path: string; handler: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; … }[]` | optional | Endpoint definitions | | **middleware** | `{ name: string; type: Enum<'authentication' \| 'authorization' \| 'logging' \| 'validation' \| 'transformation' \| 'error' \| 'custom'>; enabled: boolean; order: integer; … }[]` | optional | Middleware stack for this route group | | **authRequired** | `boolean` | ✅ | Whether authentication is required by default | | **documentation** | `{ title?: string; description?: string; tags?: string[] }` | optional | Documentation metadata for this route group | @@ -312,7 +311,7 @@ const result = ErrorHandlingConfig.parse(data); | :--- | :--- | :--- | :--- | | **path** | `string` | ✅ | Full URL path (e.g. /api/v1/analytics/query) | | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method (GET, POST, etc.) | -| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category | +| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category | | **handlerStatus** | `Enum<'implemented' \| 'stub' \| 'planned'>` | ✅ | Handler status | | **service** | `string` | ✅ | Target service name | | **healthCheckPassed** | `boolean` | optional | Whether the health check probe succeeded | @@ -329,7 +328,7 @@ const result = ErrorHandlingConfig.parse(data); | **timestamp** | `string` | ✅ | ISO 8601 timestamp | | **adapter** | `string` | ✅ | Adapter name (e.g. "hono", "express", "nextjs") | | **summary** | `{ total: integer; implemented: integer; stub: integer; planned: integer }` | ✅ | | -| **entries** | `{ path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; handlerStatus: Enum<'implemented' \| 'stub' \| 'planned'>; … }[]` | ✅ | Per-endpoint coverage entries | +| **entries** | `{ path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; handlerStatus: Enum<'implemented' \| 'stub' \| 'planned'>; … }[]` | ✅ | Per-endpoint coverage entries | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 0c185f4f2b..bd77bbecb9 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -20,8 +20,8 @@ validation. Each entry is a canonical `ActionDescriptorSchema`. ## TypeScript Usage ```typescript -import { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilities.parse(data); @@ -288,13 +288,13 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | | **failed** | `number` | ✅ | Number of records that failed | -| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | +| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | --- @@ -461,13 +461,13 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | | **failed** | `number` | ✅ | Number of records that failed | -| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | +| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | --- @@ -923,54 +923,6 @@ const result = AiAgentCapabilities.parse(data); | **view** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | View definition | ---- - -## GetWorkflowConfigRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name to get workflow config for | - - ---- - -## GetWorkflowConfigResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **workflows** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }[]` | ✅ | Active state-machine workflows for this object | - - ---- - -## GetWorkflowStateRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID to get workflow state for | - - ---- - -## GetWorkflowStateResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID | -| **state** | `{ currentState: string; availableTransitions: { name: string; targetState: string; label?: string; requiresApproval: boolean }[]; history?: { fromState: string; toState: string; action: string; userId: string; … }[] }` | ✅ | Current workflow state and available transitions | - - --- ## HttpFindQueryParams @@ -1417,13 +1369,13 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | | **failed** | `number` | ✅ | Number of records that failed | -| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | +| **results** | `{ id?: string; success: boolean; errors?: { code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record | --- @@ -1476,45 +1428,3 @@ const result = AiAgentCapabilities.parse(data); --- -## WorkflowState - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **currentState** | `string` | ✅ | Current workflow state name | -| **availableTransitions** | `{ name: string; targetState: string; label?: string; requiresApproval: boolean }[]` | ✅ | Available transitions from current state | -| **history** | `{ fromState: string; toState: string; action: string; userId: string; … }[]` | optional | State transition history | - - ---- - -## WorkflowTransitionRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID | -| **transition** | `string` | ✅ | Transition name to execute | -| **comment** | `string` | optional | Optional comment for the transition | -| **data** | `Record` | optional | Additional data for the transition | - - ---- - -## WorkflowTransitionResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID | -| **success** | `boolean` | ✅ | Whether the transition succeeded | -| **state** | `{ currentState: string; availableTransitions: { name: string; targetState: string; label?: string; requiresApproval: boolean }[]; history?: { fromState: string; toState: string; action: string; userId: string; … }[] }` | ✅ | New workflow state after transition | - - ---- - diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index cbb1e56a1e..b145dc6d0b 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -48,7 +48,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -74,7 +74,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -103,7 +103,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -149,7 +149,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -163,7 +163,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -177,7 +177,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -204,7 +204,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -218,7 +218,7 @@ const result = CompleteChunkedUploadRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_DATASOURCE_ERROR' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'RESUME_FAILED' \| 'RESUME_IN_PROGRESS' \| 'RESUME_TARGET_LOST' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'RUN_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'STORE_UNAVAILABLE' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/content/docs/references/automation/connector.mdx b/content/docs/references/automation/connector.mdx index f8baede8f5..bfa05440a7 100644 --- a/content/docs/references/automation/connector.mdx +++ b/content/docs/references/automation/connector.mdx @@ -5,65 +5,16 @@ description: Connector protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/connector.zod.ts` - - ## TypeScript Usage ```typescript -import { Connector, ConnectorTrigger, DataSyncConfig } from '@objectstack/spec/automation'; -import type { Connector, ConnectorTrigger, DataSyncConfig } from '@objectstack/spec/automation'; +import { DataSyncConfig } from '@objectstack/spec/automation'; +import type { DataSyncConfig } from '@objectstack/spec/automation'; // Validate data -const result = Connector.parse(data); +const result = DataSyncConfig.parse(data); ``` ---- - -## Connector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Connector ID (snake_case) | -| **name** | `string` | ✅ | Connector name | -| **description** | `string` | optional | Connector description | -| **version** | `string` | optional | Connector version | -| **icon** | `string` | optional | Connector icon | -| **category** | `Enum<'crm' \| 'payment' \| 'communication' \| 'storage' \| 'analytics' \| 'database' \| 'marketing' \| 'accounting' \| 'hr' \| 'productivity' \| 'ecommerce' \| 'support' \| 'devtools' \| 'social' \| 'other'>` | ✅ | Connector category | -| **baseUrl** | `string` | optional | API base URL | -| **authentication** | `{ type: Enum<'none' \| 'apiKey' \| 'basic' \| 'bearer' \| 'oauth1' \| 'oauth2' \| 'custom'>; fields?: { name: string; label: string; type: Enum<'text' \| 'password' \| 'url' \| 'select'>; description?: string; … }[]; oauth2?: object; test?: object }` | ✅ | Authentication config | -| **operations** | `{ id: string; name: string; description?: string; type: Enum<'read' \| 'write' \| 'delete' \| 'search' \| 'trigger' \| 'action'>; … }[]` | optional | Connector operations | -| **triggers** | `{ id: string; name: string; description?: string; type: Enum<'webhook' \| 'polling' \| 'stream'>; … }[]` | optional | Connector triggers | -| **rateLimit** | `{ requestsPerSecond?: number; requestsPerMinute?: number; requestsPerHour?: number }` | optional | Rate limiting | -| **author** | `string` | optional | Connector author | -| **documentation** | `string` | optional | Documentation URL | -| **homepage** | `string` | optional | Homepage URL | -| **license** | `string` | optional | License (SPDX identifier) | -| **tags** | `string[]` | optional | Connector tags | -| **verified** | `boolean` | ✅ | Verified connector | -| **metadata** | `Record` | optional | Custom metadata | - - ---- - -## ConnectorTrigger - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Trigger ID (snake_case) | -| **name** | `string` | ✅ | Trigger name | -| **description** | `string` | optional | Trigger description | -| **type** | `Enum<'webhook' \| 'polling' \| 'stream'>` | ✅ | Trigger mechanism | -| **config** | `Record` | optional | Trigger configuration | -| **outputSchema** | `Record` | optional | Event payload schema | -| **pollingIntervalMs** | `integer` | optional | Polling interval in ms | - - --- ## DataSyncConfig diff --git a/content/docs/references/automation/events-core.mdx b/content/docs/references/automation/events-core.mdx new file mode 100644 index 0000000000..43d096bc0c --- /dev/null +++ b/content/docs/references/automation/events-core.mdx @@ -0,0 +1,31 @@ +--- +title: Events Core +description: Events Core protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## TypeScript Usage + +```typescript +import { Event } from '@objectstack/spec/automation'; +import type { Event } from '@objectstack/spec/automation'; + +// Validate data +const result = Event.parse(data); +``` + +--- + +## Event + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Event Type (e.g. "APPROVE", "REJECT", "Submit") | +| **schema** | `Record` | optional | Expected event payload structure | + + +--- + diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 344b10731e..78d1d46dfb 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -84,7 +84,7 @@ const result = Flow.parse(data); | **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) returning boolean used for branching. | | **type** | `Enum<'default' \| 'fault' \| 'conditional' \| 'back'>` | optional | Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node) | | **label** | `string` | optional | Label on the connector | -| **isDefault** | `boolean` | optional | Marks this edge as the default path when no other conditions match | +| **isDefault** | `boolean` | optional | BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with `condition`; at most one per source node. | --- diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index fe6cef7bf8..604be92f4b 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -20,6 +20,5 @@ This section contains all protocol schemas for the automation layer of ObjectSta - diff --git a/content/docs/references/automation/job.mdx b/content/docs/references/automation/job.mdx index 8f79ea972b..d3e8b86a7e 100644 --- a/content/docs/references/automation/job.mdx +++ b/content/docs/references/automation/job.mdx @@ -5,10 +5,6 @@ description: Job protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/job.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 30a557be1d..1021c8f5b5 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -8,7 +8,6 @@ "node-executor", "state-machine", "time-relative-trigger", - "trigger-registry", "---Integration & Data---", "bpmn-interop", "connector", @@ -21,6 +20,7 @@ "job", "---More---", "builtin-node-config", + "events-core", "flow-function", "io-node-config", "schemaless-node-config" diff --git a/content/docs/references/automation/offline.mdx b/content/docs/references/automation/offline.mdx index 00fb908d98..f6bfbe5d6d 100644 --- a/content/docs/references/automation/offline.mdx +++ b/content/docs/references/automation/offline.mdx @@ -5,10 +5,6 @@ description: Offline protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/offline.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index ab971fe805..84b5990d25 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -19,21 +19,27 @@ form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table — with each member's reason: `decision`'s virtual Target column is derived from -the out-edges, `script`'s form switches on `actionType`, `subflow` carries a +the out-edges, `subflow` carries a top-level `timeoutMs` — a published -top-level `timeoutMs` — a published partial schema would DROP those editors +partial schema would DROP those editors (the #4210 `connector_action` -(the #4210 `connector_action` incident). So the Studio form for these types +incident). So the Studio form for these types is objectui's hand-written -is objectui's hand-written group, and until #4278 **nothing reconciled that +group, and until #4278 **nothing reconciled that hand-written table against -hand-written table against the executors**: `script`'s form offered an +the executors**: `script`'s form offered an `outputVariables` key nothing -`outputVariables` key nothing reads, two `actionType` options that fail every +reads, two `actionType` options that fail every run, a no-op default — and -run, a no-op default — and could not author the `function`/`inputs`/ +could not author the `function`/`inputs`/`outputVariable` path that works. -`outputVariable` path that works. +`script`'s own reason for staying schemaless was that its form switched on + +`actionType`. #4343 retired that switch, so the node is now three flat keys + +and could graduate to a published descriptor `configSchema` the way `map` + +did — a follow-up, deliberately not folded into the retirement. These schemas are the machine-readable half of that reconciliation. They are @@ -59,37 +65,57 @@ entry here: their contracts are the spec-structured sibling blocks on same objectui test reconciles directly. -## What these schemas are (and are not) wired to +## What these schemas are wired to + +`script` and `subflow` are **parsed at execute time** since #4343, through + +the same `parseNodeConfig()` seam #4277 gave the flat builtins + +(`service-automation`'s `parse-config.ts`): a config that fails its contract + +refuses the node as a GUARD — wrong metadata, so a rerun cannot help and no + +`fault` edge may route it (#3863). + +`script` could not be parsed while its legal key set depended on + +`actionType`; #4343 removed that dependence instead of modelling it. + +Converging the node to its one real path — call a registered function — left + +a flat three-key contract a flat parse fits exactly, and the five keys the + +other branches read became `retiredKey` tombstones. -Contract exports only — no engine path `parse()`s a node config with them, +The two halves reach different audiences, which is why they shipped together: -so registering a flow behaves exactly as before. This is where they differ +- the **tombstones** teach whoever authors the key — `tsc` types it `never`, -from their `builtin-node-config.zod.ts` siblings, which #4277 wired into +and a direct parse raises the prescription. They do NOT reach a stored -execute-time parsing (`service-automation`'s `parse-config.ts`) and into the +flow: `FlowNodeSchema.config` is `z.record(z.unknown())`, so no load-path -`registerFlow()` unknown-key rejection. +parse ever descends into a node's config; -That difference is deliberate, and it is the same reason these three publish +- the **execute-time parse** is what a stored flow meets. `registerFlow` -no descriptor `configSchema`: **their key set is not the whole contract.** +canonicalizes data at rest through the retired conversion too (#3903), so -`script`'s legal keys depend on `actionType` (a built-in side effect reads +a stored `actionType: 'email'` node arrives here stripped of the keys -`template`/`recipients`/`variables`; the function path reads +nothing read — and then refuses, naming the `function` it does not have, -`function`/`inputs`/`outputVariable`), and `decision` may carry no +instead of logging a line and reporting success as it used to. -`conditions` at all when it branches purely on edge predicates. A flat parse +`decision` stays export-only, deliberately: it may carry no `conditions` at -would either reject those shapes or wave everything through — neither is the +all when it branches purely on edge predicates (a plain BPMN exclusive -contract. Wiring them in needs a discriminated form first; until then the +gateway), and `conditions` is its only key — so a parse would have nothing -enforcement they DO get is the objectui reconciliation test, which is what +left to check. Its enforcement remains the objectui reconciliation test, -#4278 was actually about (a form authoring keys nothing reads). +which is what #4278 was actually about (a form authoring keys nothing reads). Undeclared aliases are NOT part of these contracts: `subflow`'s historical @@ -121,7 +147,7 @@ const result = DecisionCondition.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **label** | `string` | ✅ | Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) | +| **label** | `string` | ✅ | Branch label; the winning branch resumes down the out-edge with this label (no match → the out-edge marked isDefault, or one labelled 'default') | | **expression** | `string` | ✅ | Bare CEL predicate deciding this branch | @@ -144,14 +170,14 @@ const result = DecisionCondition.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **actionType** | `string` | optional | How this step runs: a built-in side effect ('email' \| 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name | -| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists | +| **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists | | **inputs** | `Record` | optional | Inputs passed to the function (values interpolate `{token}` templates) | | **outputVariable** | `string` | optional | Flow variable the function's return value is bound to | -| **template** | `string` | optional | Built-in side effects only: message template id | -| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) | -| **variables** | `Record` | optional | Built-in side effects only: values injected into the template | -| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead | +| **actionType** | `any` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. | +| **template** | `any` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to rewrite it automatically. | +| **recipients** | `any` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to rewrite it automatically. | +| **variables** | `any` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to rewrite it automatically. | +| **script** | `any` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. | --- diff --git a/content/docs/references/automation/state-machine.mdx b/content/docs/references/automation/state-machine.mdx index 6ecf8900b4..59601ec4cd 100644 --- a/content/docs/references/automation/state-machine.mdx +++ b/content/docs/references/automation/state-machine.mdx @@ -18,8 +18,8 @@ Prevent AI "hallucinations" by enforcing valid valid transitions. ## TypeScript Usage ```typescript -import { ActionRef, Event, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; -import type { ActionRef, Event, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; +import { ActionRef, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; +import type { ActionRef, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; // Validate data const result = ActionRef.parse(data); @@ -53,18 +53,6 @@ Type: `string` --- ---- - -## Event - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | Event Type (e.g. "APPROVE", "REJECT", "Submit") | -| **schema** | `Record` | optional | Expected event payload structure | - - --- ## GuardRef diff --git a/content/docs/references/automation/trigger-registry.mdx b/content/docs/references/automation/trigger-registry.mdx deleted file mode 100644 index f8230c8d24..0000000000 --- a/content/docs/references/automation/trigger-registry.mdx +++ /dev/null @@ -1,273 +0,0 @@ ---- -title: Trigger Registry -description: Trigger Registry protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Trigger Registry Protocol - -Lightweight automation triggers for simple integrations. - -Inspired by Zapier, n8n, and Workato connector architectures. - -## When to use Trigger Registry vs. Integration Connector? - -**Use `[automation/trigger-registry.zod.ts](/docs/references/automation/trigger-registry)` when:** - -- Building simple automation triggers (e.g., "when Slack message received, create task") - -- No complex authentication needed (simple API keys, basic auth) - -- Lightweight, single-purpose integrations - -- Quick setup with minimal configuration - -- Webhook-based or polling triggers for automation workflows - -**Use `[integration/connector.zod.ts](/docs/references/integration/connector)` when:** - -- Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) - -- Complex OAuth2/SAML authentication required - -- Bidirectional sync with field mapping and transformations - -- Webhook management and rate limiting required - -- Full CRUD operations and data synchronization - -## Use Cases - -1. **Simple Automation Triggers** - -- Slack notifications on record updates - -- Twilio SMS on workflow events - -- SendGrid email templates - -2. **Lightweight Operations** - -- Single-action integrations (send, notify, log) - -- No bidirectional sync required - -- Webhook receivers for incoming events - -3. **Quick Integrations** - -- Payment webhooks (Stripe, PayPal) - -- Communication triggers (Twilio, SendGrid, Slack) - -- Simple API calls to third-party services - -See also: https://zapier.com/developer/documentation/v2/ - -See also: https://docs.n8n.io/integrations/creating-nodes/ - -See also: ../../[integration/connector.zod.ts](/docs/references/integration/connector) for enterprise connectors - -@example - -```typescript - -const slackNotifier: Connector = \{ - -id: 'slack_notify', - -name: 'Slack Notification', - -category: 'communication', - -authentication: \{ - -type: 'apiKey', - -fields: [\{ name: 'webhook_url', label: 'Webhook URL', type: 'url' \}] - -\}, - -operations: [ - -\{ id: 'send_message', name: 'Send Message', type: 'action' \} - -] - -\} - -``` - - -**Source:** `packages/spec/src/automation/trigger-registry.zod.ts` - - -## TypeScript Usage - -```typescript -import { AuthField, Authentication, AuthenticationType, ConnectorCategory, ConnectorInstance, ConnectorOperation, OAuth2Config, OperationParameter, OperationType } from '@objectstack/spec/automation'; -import type { AuthField, Authentication, AuthenticationType, ConnectorCategory, ConnectorInstance, ConnectorOperation, OAuth2Config, OperationParameter, OperationType } from '@objectstack/spec/automation'; - -// Validate data -const result = AuthField.parse(data); -``` - ---- - -## AuthField - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Field name (snake_case) | -| **label** | `string` | ✅ | Field label | -| **type** | `Enum<'text' \| 'password' \| 'url' \| 'select'>` | ✅ | Field type | -| **description** | `string` | optional | Field description | -| **required** | `boolean` | ✅ | Required field | -| **default** | `string` | optional | Default value | -| **options** | `{ label: string; value: string }[]` | optional | Select field options | -| **placeholder** | `string` | optional | Placeholder text | - - ---- - -## Authentication - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'none' \| 'apiKey' \| 'basic' \| 'bearer' \| 'oauth1' \| 'oauth2' \| 'custom'>` | ✅ | Authentication type | -| **fields** | `{ name: string; label: string; type: Enum<'text' \| 'password' \| 'url' \| 'select'>; description?: string; … }[]` | optional | Authentication fields | -| **oauth2** | `{ authorizationUrl: string; tokenUrl: string; scopes?: string[]; clientIdField: string; … }` | optional | OAuth 2.0 configuration | -| **test** | `{ url?: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE'> }` | optional | Authentication test configuration | - - ---- - -## AuthenticationType - -### Allowed Values - -* `none` -* `apiKey` -* `basic` -* `bearer` -* `oauth1` -* `oauth2` -* `custom` - - ---- - -## ConnectorCategory - -### Allowed Values - -* `crm` -* `payment` -* `communication` -* `storage` -* `analytics` -* `database` -* `marketing` -* `accounting` -* `hr` -* `productivity` -* `ecommerce` -* `support` -* `devtools` -* `social` -* `other` - - ---- - -## ConnectorInstance - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Instance ID | -| **connectorId** | `string` | ✅ | Connector ID | -| **name** | `string` | ✅ | Instance name | -| **description** | `string` | optional | Instance description | -| **credentials** | `Record` | ✅ | Encrypted credentials | -| **config** | `Record` | optional | Additional config | -| **active** | `boolean` | ✅ | Instance active status | -| **createdAt** | `string` | optional | Creation time | -| **lastTestedAt** | `string` | optional | Last test time | -| **testStatus** | `Enum<'unknown' \| 'success' \| 'failed'>` | ✅ | Connection test status | - - ---- - -## ConnectorOperation - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Operation ID (snake_case) | -| **name** | `string` | ✅ | Operation name | -| **description** | `string` | optional | Operation description | -| **type** | `Enum<'read' \| 'write' \| 'delete' \| 'search' \| 'trigger' \| 'action'>` | ✅ | Operation type | -| **inputSchema** | `{ name: string; label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'array' \| 'object' \| 'date' \| 'file'>; … }[]` | optional | Input parameters | -| **outputSchema** | `Record` | optional | Output schema | -| **sampleOutput** | `any` | optional | Sample output | -| **supportsPagination** | `boolean` | ✅ | Supports pagination | -| **supportsFiltering** | `boolean` | ✅ | Supports filtering | - - ---- - -## OAuth2Config - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **authorizationUrl** | `string` | ✅ | Authorization endpoint URL | -| **tokenUrl** | `string` | ✅ | Token endpoint URL | -| **scopes** | `string[]` | optional | OAuth scopes | -| **clientIdField** | `string` | ✅ | Client ID field name | -| **clientSecretField** | `string` | ✅ | Client secret field name | - - ---- - -## OperationParameter - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Parameter name | -| **label** | `string` | ✅ | Parameter label | -| **description** | `string` | optional | Parameter description | -| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'array' \| 'object' \| 'date' \| 'file'>` | ✅ | Parameter type | -| **required** | `boolean` | ✅ | Required parameter | -| **default** | `any` | optional | Default value | -| **validation** | `Record` | optional | Validation rules | -| **dynamicOptions** | `string` | optional | Function to load dynamic options | - - ---- - -## OperationType - -### Allowed Values - -* `read` -* `write` -* `delete` -* `search` -* `trigger` -* `action` - - ---- - diff --git a/content/docs/references/cloud/plugin-security.mdx b/content/docs/references/cloud/plugin-security.mdx index 877edc19be..a6501678f8 100644 --- a/content/docs/references/cloud/plugin-security.mdx +++ b/content/docs/references/cloud/plugin-security.mdx @@ -5,10 +5,6 @@ description: Plugin Security protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/cloud/plugin-security.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/cloud/provisioning.mdx b/content/docs/references/cloud/provisioning.mdx index 126b63717c..bbcf0d592a 100644 --- a/content/docs/references/cloud/provisioning.mdx +++ b/content/docs/references/cloud/provisioning.mdx @@ -5,10 +5,6 @@ description: Provisioning protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/cloud/provisioning.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index 0bea2ed219..f698fdd6e2 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -36,7 +36,6 @@ const result = Datasource.parse(data); | **driver** | `string` | ✅ | Underlying driver type | | **config** | `Record` | ✅ | Driver specific configuration | | **pool** | `{ min: number; max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }` | optional | Connection pool settings | -| **readReplicas** | `Record[]` | optional | Read-only replica configurations | | **capabilities** | `{ transactions: boolean; queryFilters: boolean; queryAggregations: boolean; querySorting: boolean; … }` | optional | Capability overrides | | **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number }` | optional | Datasource health check configuration | | **ssl** | `{ enabled: boolean; rejectUnauthorized: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration for secure database connections | @@ -47,6 +46,13 @@ const result = Datasource.parse(data); | **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` | ✅ | Schema ownership mode | | **external** | `{ label?: string; allowedSchemas?: string[]; allowWrites: boolean; validation: object; … }` | optional | External datasource federation settings (schemaMode != "managed") | | **origin** | `Enum<'code' \| 'runtime'>` | ✅ | Datasource provenance (server-managed, read-only) | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/data/driver-common.mdx b/content/docs/references/data/driver-common.mdx new file mode 100644 index 0000000000..db66f93b5d --- /dev/null +++ b/content/docs/references/data/driver-common.mdx @@ -0,0 +1,62 @@ +--- +title: Driver Common +description: Driver Common protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Shared building blocks for the per-driver `datasource.config` shapes (#4410). + +Every schema under `data/driver/` describes ONE driver's `config` slot — the + +keys an author may write and the platform actually reads. They are the + +enforcement half of the `config` escape hatch `datasource.zod.ts` opens: the + +slot stays `z.record` at the top of `DatasourceSchema` because a sqlite + +`filename` and a postgres `host` share no shape, and `DatasourceSchema`'s + +refinement then parses it against the schema for the declared driver. + +The rule these files are written to: **a key is declared here only if some + +code path reads it.** A config key that no driver and no factory consumes is + +the same silent-strip defect one level down (#4001, ADR-0078), so an unread + +key is either wired or rejected with a prescription — never left in the + +contract to look supported. + + +**Source:** `packages/spec/src/data/driver/common.zod.ts` + + +## TypeScript Usage + +```typescript +import { DriverSslToggle, SqlAutoMigrate } from '@objectstack/spec/data'; +import type { DriverSslToggle, SqlAutoMigrate } from '@objectstack/spec/data'; + +// Validate data +const result = DriverSslToggle.parse(data); +``` + +--- + + +--- + +## SqlAutoMigrate + +Dev-only non-destructive schema self-heal (#2186) + +### Allowed Values + +* `off` +* `safe` + + +--- + diff --git a/content/docs/references/data/driver-memory.mdx b/content/docs/references/data/driver-memory.mdx new file mode 100644 index 0000000000..58e37f2916 --- /dev/null +++ b/content/docs/references/data/driver-memory.mdx @@ -0,0 +1,101 @@ +--- +title: Driver Memory +description: Driver Memory protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Memory Driver Configuration Schema + +Defines the configuration options for the in-memory driver. + +Reference: objectql/packages/drivers/memory (Mingo-powered production-ready driver) + +The memory driver is ideal for: + +- Unit testing (no database setup required) + +- Development & prototyping + +- Edge/Worker environments (Cloudflare Workers, Deno Deploy) + +- Client-side state management + +- Temporary data caching + +- CI/CD pipelines + + +**Source:** `packages/spec/src/data/driver/memory.zod.ts` + + +## TypeScript Usage + +```typescript +import { AutoPersistenceConfig, FilePersistenceConfig, LocalStoragePersistenceConfig, PersistenceType } from '@objectstack/spec/data'; +import type { AutoPersistenceConfig, FilePersistenceConfig, LocalStoragePersistenceConfig, PersistenceType } from '@objectstack/spec/data'; + +// Validate data +const result = AutoPersistenceConfig.parse(data); +``` + +--- + +## AutoPersistenceConfig + +Auto-detect persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'auto'` | ✅ | | +| **path** | `string` | optional | File path override for Node.js environments | +| **autoSaveInterval** | `number` | optional | Auto-save interval override for Node.js environments | +| **key** | `string` | optional | localStorage key override for browser environments | + + +--- + +## FilePersistenceConfig + +File-system persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'file'` | ✅ | | +| **path** | `string` | optional | File path to persist data | +| **autoSaveInterval** | `number` | ✅ | Auto-save interval in ms | + + +--- + +## LocalStoragePersistenceConfig + +localStorage persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'local'` | ✅ | | +| **key** | `string` | optional | localStorage key for persisted data | + + +--- + +## PersistenceType + +Persistence backend type + +### Allowed Values + +* `file` +* `local` +* `auto` + + +--- + diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx new file mode 100644 index 0000000000..102f346f06 --- /dev/null +++ b/content/docs/references/data/driver-mongo.mdx @@ -0,0 +1,61 @@ +--- +title: Driver Mongo +description: Driver Mongo protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +MongoDB Standard Driver Protocol + +Describes the MongoDB connection settings and capabilities. + +ENFORCED as of #4410. This block used to claim it was "used by the Platform + +to validate `datasource.config` when `driver: 'mongo'`", which was false: the + +config slot was a bare `z.record` and this schema had no consumer at all — + +not even an export, since `data/driver/` was reachable only from its own + +tests. It is now what `DatasourceSchema` parses `config` against for a mongo + +datasource, and the same schema is projected onto + +`MongoDriverSpec`.configSchema for the connection form. + + +**Source:** `packages/spec/src/data/driver/mongo.zod.ts` + + +## TypeScript Usage + +```typescript +import { MongoConfig } from '@objectstack/spec/data'; +import type { MongoConfig } from '@objectstack/spec/data'; + +// Validate data +const result = MongoConfig.parse(data); +``` + +--- + +## MongoConfig + +MongoDB Connection Configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **database** | `string` | optional | Database name | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **authSource** | `string` | optional | Authentication database | +| **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) | + + +--- + diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx new file mode 100644 index 0000000000..418dced513 --- /dev/null +++ b/content/docs/references/data/driver-mysql.mdx @@ -0,0 +1,63 @@ +--- +title: Driver Mysql +description: Driver Mysql protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +MySQL / MariaDB driver configuration — the `config` slot of a `datasource` + +whose `driver` resolves to `mysql` (`mysql2`). + +The driver id was offered by the connection form and buildable by the shared + +factory long before #4410, but had no config shape at all in `packages/spec` + +— postgres, mongo and memory each had one and mysql did not, so its `config` + +was the one slot with neither a gate nor a documented shape. + +Every key here is read by `createDefaultDatasourceDriverFactory` + +(→ `SqlDriver`, knex `mysql2`). Postgres-only knobs are deliberately absent: + +`mysql2` has no `application_name` and no `statement_timeout`, so declaring + +them would advertise settings the client drops. + + +**Source:** `packages/spec/src/data/driver/mysql.zod.ts` + + +## TypeScript Usage + +```typescript +import { MysqlConfig } from '@objectstack/spec/data'; +import type { MysqlConfig } from '@objectstack/spec/data'; + +// Validate data +const result = MysqlConfig.parse(data); +``` + +--- + +## MysqlConfig + +MySQL / MariaDB connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **database** | `string` | optional | Database name | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx new file mode 100644 index 0000000000..66bf74e48f --- /dev/null +++ b/content/docs/references/data/driver-postgres.mdx @@ -0,0 +1,62 @@ +--- +title: Driver Postgres +description: Driver Postgres protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +PostgreSQL driver configuration — the `config` slot of a `datasource` whose + +`driver` resolves to `postgres` (`pg` / `postgresql`). + +ENFORCED as of #4410: `DatasourceSchema` parses `config` against this schema, + +so a misspelled connection key fails at authoring time instead of leaving the + +datasource on the client's localhost defaults. Every key here is read by + +`createDefaultDatasourceDriverFactory` (→ `SqlDriver`, knex `pg`). + +Pool sizing is NOT here: it lives in the driver-agnostic `datasource.pool` + +block, which the factory now honours for every SQL driver. + + +**Source:** `packages/spec/src/data/driver/postgres.zod.ts` + + +## TypeScript Usage + +```typescript +import { PostgresConfig } from '@objectstack/spec/data'; +import type { PostgresConfig } from '@objectstack/spec/data'; + +// Validate data +const result = PostgresConfig.parse(data); +``` + +--- + +## PostgresConfig + +PostgreSQL connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **database** | `string` | optional | Database name | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | +| **schema** | `string` | ✅ | Default schema (knex searchPath) | +| **applicationName** | `string` | optional | Postgres application_name | +| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + diff --git a/content/docs/references/data/driver-sqlite.mdx b/content/docs/references/data/driver-sqlite.mdx new file mode 100644 index 0000000000..ba941d67bf --- /dev/null +++ b/content/docs/references/data/driver-sqlite.mdx @@ -0,0 +1,106 @@ +--- +title: Driver Sqlite +description: Driver Sqlite protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +SQLite driver configuration — the `config` slot of a `datasource` whose + +`driver` resolves to `sqlite` (native `better-sqlite3`, with the dev-only + +step-down to wasm then in-memory, #2229) or to `sqlite-wasm` (pure-JS). + +The one key that matters is `filename`, and it is exactly the key the silent + +strip used to hide: an author who wrote `path:` got no error, the connection + +fell back to `:memory:`, and their data vanished on restart with every signal + +saying the datasource was configured. + +`file` and `database` are a different case — the factory reads them as + +undeclared `??` fallbacks, so they happened to work while being documented + +nowhere. They are named as renames here rather than blessed: one strict + +contract beats a spelling that works only because a reader is lenient + +(AGENTS.md Prime Directive #12). The factory keeps its tolerance for records + +already persisted that way; no new one can be authored. + + +**Source:** `packages/spec/src/data/driver/sqlite.zod.ts` + + +## TypeScript Usage + +```typescript +import { SqliteConfig, SqliteWasmConfig, SqliteWasmPersistMode } from '@objectstack/spec/data'; +import type { SqliteConfig, SqliteWasmConfig, SqliteWasmPersistMode } from '@objectstack/spec/data'; + +// Validate data +const result = SqliteConfig.parse(data); +``` + +--- + +## SqliteConfig + +SQLite connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **filename** | `string` | ✅ | Database file path, or ":memory:" for an ephemeral database | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + +## SqliteWasmConfig + +SQLite (WASM) connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **filename** | `string` | ✅ | Database file path, or ":memory:" for an ephemeral database | +| **persist** | `'on-disconnect' \| 'on-write' \| string` | optional | When to flush a file-backed wasm database to disk | + + +--- + +## SqliteWasmPersistMode + +When to flush a file-backed wasm database to disk + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Type: `'on-disconnect'` + +--- + +#### Option 2 + +Type: `'on-write'` + +--- + +#### Option 3 + +Type: `string` + +--- + + +--- + diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 207d74e17e..ce71a76e1c 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -13,8 +13,14 @@ This section contains all protocol schemas for the data layer of ObjectStack. + + + + + + diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index e8e922b97e..d53d6530bc 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -28,6 +28,12 @@ "seed-loader", "---More---", "context-tokens", + "driver-common", + "driver-memory", + "driver-mongo", + "driver-mysql", + "driver-postgres", + "driver-sqlite", "field-value" ] } \ No newline at end of file diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx index 805518cbf7..d8ef46d3e8 100644 --- a/content/docs/references/data/seed.mdx +++ b/content/docs/references/data/seed.mdx @@ -36,6 +36,13 @@ const result = Seed.parse(data); | **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | ✅ | Conflict resolution strategy | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | ✅ | Applicable environments | | **records** | `Record[]` | ✅ | Data records | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 07c912df63..0929d1f3bd 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Complete reference of all 139 ObjectStack protocol specifications +description: Complete reference of all 133 ObjectStack protocol specifications --- # Protocol Reference @@ -30,7 +30,7 @@ These reference pages are **auto-generated** from the Zod source files in `packa | [QA Protocol](#qa-protocol) | 1 | Test Suites and BDD Scenarios | | [Studio Protocol](#studio-protocol) | 1 | Studio plugin development | -**Total: 175 Zod schemas** (across 14 protocol modules + 1 root stack schema) +**Total: 169 Zod schemas** (across 14 protocol modules + 1 root stack schema) --- @@ -313,19 +313,18 @@ Defines marketplace and multi-tenancy capabilities. ## Integration Protocol **Location:** `packages/spec/src/integration/` -**Count:** 7 schemas +**Count:** 1 schema -Defines external system connectors and adapters. +Defines external system connectors — one protocol (ADR-0097). A connector +entry is either a catalog descriptor or a provider-bound instance that a +generic executor (connector-openapi / connector-mcp) materializes at boot. +The per-provider "templates" (`connector/saas.zod.ts` and five siblings) were +removed in #4480: they hand-modelled each external system's shape inside the +spec, which ADR-0023 rejected, and nothing ever consumed them. | File | Schema | Purpose | | :--- | :--- | :--- | -| `connector.zod.ts` | `ConnectorSchema` | Generic connector interface | -| `connector/saas.zod.ts` | `SaaSConnectorSchema` | SaaS platform connectors (Salesforce, HubSpot, etc.) | -| `connector/database.zod.ts` | `DatabaseConnectorSchema` | Database connection adapters | -| `connector/file-storage.zod.ts` | `FileStorageConnectorSchema` | Cloud storage connectors (S3, Azure Blob, etc.) | -| `connector/message-queue.zod.ts` | `MessageQueueConnectorSchema` | Message queue integrations (RabbitMQ, Kafka, etc.) | -| `connector/github.zod.ts` | `GitHubConnectorSchema` | GitHub API integration | -| `connector/vercel.zod.ts` | `VercelConnectorSchema` | Vercel deployment integration | +| `connector.zod.ts` | `ConnectorSchema` | The connector protocol — auth, sync, webhooks, rate limiting | **Learn more:** [Integration Protocol Reference](/docs/references/integration) diff --git a/content/docs/references/integration/connector-auth.mdx b/content/docs/references/integration/connector-auth.mdx index ac2912aaae..c74b8dfe55 100644 --- a/content/docs/references/integration/connector-auth.mdx +++ b/content/docs/references/integration/connector-auth.mdx @@ -5,10 +5,6 @@ description: Connector Auth protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/connector-auth.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 7033d9ebec..e9505e0a0f 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -103,33 +103,27 @@ See also: [../[automation/sync.zod.ts](/docs/references/automation/sync)](/docs/ See also: [../[automation/etl.zod.ts](/docs/references/automation/etl)](/docs/references/automation/etl) for Level 2 (data engineering) -## When to use Integration Connector vs. Trigger Registry? +## There is no "Trigger Registry" alternative -**Use `[integration/connector.zod.ts](/docs/references/integration/connector)` when:** +This header used to carry a "When to use Integration Connector vs. Trigger -- Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) +Registry?" comparison, steering "lightweight" cases to -- Complex OAuth2/SAML authentication required +`[automation/trigger-registry.zod.ts](/docs/references/automation/trigger-registry)`. That file was a third declaration of -- Bidirectional sync with field mapping and transformations - -- Webhook management and rate limiting required - -- Full CRUD operations and data synchronization - -- Need comprehensive retry strategies and error handling +the connector vocabulary with zero consumers — nothing registered, validated -**Use `[automation/trigger-registry.zod.ts](/docs/references/automation/trigger-registry)` when:** +or executed against it — so the guidance pointed authors, with the -- Building simple automation triggers (e.g., "when Slack message received, create task") +platform's authority, at a dead end (#4499; removed alongside the #4480 -- No complex authentication needed (simple API keys, basic auth) +per-provider template cluster). The same defect class as the -- Lightweight, single-purpose integrations +`capabilities.readOnly` prescription #4487 corrected: a signpost must land -- Quick setup with minimal configuration +somewhere enforced. Lightweight cases are served HERE — a connector instance -See also: ../../[automation/trigger-registry.zod.ts](/docs/references/automation/trigger-registry) for lightweight automation triggers +with simple `auth` — or by `[automation/sync.zod.ts](/docs/references/automation/sync)` / `etl.zod.ts` below. **Source:** `packages/spec/src/integration/connector.zod.ts` diff --git a/content/docs/references/integration/http.mdx b/content/docs/references/integration/http.mdx index 17663c9578..3ad0fbc34a 100644 --- a/content/docs/references/integration/http.mdx +++ b/content/docs/references/integration/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/mapping.mdx b/content/docs/references/integration/mapping.mdx index b82d0245a4..6ec881b433 100644 --- a/content/docs/references/integration/mapping.mdx +++ b/content/docs/references/integration/mapping.mdx @@ -5,10 +5,6 @@ description: Mapping protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/mapping.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/message-queue.mdx b/content/docs/references/integration/message-queue.mdx deleted file mode 100644 index 4e9df76ec0..0000000000 --- a/content/docs/references/integration/message-queue.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Message Queue -description: Message Queue protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/integration/message-queue.zod.ts` - - -## TypeScript Usage - -```typescript -import { ConsumerConfig, MessageQueueProvider } from '@objectstack/spec/integration'; -import type { ConsumerConfig, MessageQueueProvider } from '@objectstack/spec/integration'; - -// Validate data -const result = ConsumerConfig.parse(data); -``` - ---- - -## ConsumerConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable consumer | -| **consumerGroup** | `string` | optional | Consumer group ID | -| **concurrency** | `number` | ✅ | Number of concurrent consumers | -| **prefetchCount** | `number` | ✅ | Prefetch count | -| **ackMode** | `Enum<'auto' \| 'manual' \| 'client'>` | ✅ | Message acknowledgment mode | -| **autoCommit** | `boolean` | ✅ | Auto-commit offsets | -| **autoCommitIntervalMs** | `number` | ✅ | Auto-commit interval in ms | -| **sessionTimeoutMs** | `number` | ✅ | Session timeout in ms | -| **rebalanceTimeoutMs** | `number` | optional | Rebalance timeout in ms | - - ---- - -## MessageQueueProvider - -Message queue provider type - -### Allowed Values - -* `rabbitmq` -* `kafka` -* `redis_pubsub` -* `redis_streams` -* `aws_sqs` -* `aws_sns` -* `google_pubsub` -* `azure_service_bus` -* `azure_event_hubs` -* `nats` -* `pulsar` -* `activemq` -* `custom` - - ---- - diff --git a/content/docs/references/integration/meta.json b/content/docs/references/integration/meta.json index d672bdfa95..b125c0f212 100644 --- a/content/docs/references/integration/meta.json +++ b/content/docs/references/integration/meta.json @@ -7,11 +7,6 @@ "mapping", "---Transport & Storage---", "http", - "message-queue", - "object-storage", - "offline", - "---Tenancy---", - "misc", - "tenant" + "offline" ] } \ No newline at end of file diff --git a/content/docs/references/integration/misc.mdx b/content/docs/references/integration/misc.mdx deleted file mode 100644 index cdc46335fc..0000000000 --- a/content/docs/references/integration/misc.mdx +++ /dev/null @@ -1,860 +0,0 @@ ---- -title: Misc -description: Misc protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/integration/misc.zod.ts` - - -## TypeScript Usage - -```typescript -import { AckMode, ApiVersionConfig, BuildConfig, CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, DeliveryGuarantee, DeploymentConfig, DlqConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository, GitRepositoryConfig, MessageFormat, MessageQueueConnector, ProducerConfig, SaasConnector, SaasObjectType, SaasProvider, SslConfig, StorageBucket, TopicQueue, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; -import type { AckMode, ApiVersionConfig, BuildConfig, CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, DeliveryGuarantee, DeploymentConfig, DlqConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository, GitRepositoryConfig, MessageFormat, MessageQueueConnector, ProducerConfig, SaasConnector, SaasObjectType, SaasProvider, SslConfig, StorageBucket, TopicQueue, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; - -// Validate data -const result = AckMode.parse(data); -``` - ---- - -## AckMode - -Message acknowledgment mode - -### Allowed Values - -* `auto` -* `manual` -* `client` - - ---- - -## ApiVersionConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **version** | `string` | ✅ | API version (e.g., "v2", "2023-10-01") | -| **isDefault** | `boolean` | ✅ | Is this the default version | -| **deprecationDate** | `string` | optional | API version deprecation date (ISO 8601) | -| **sunsetDate** | `string` | optional | API version sunset date (ISO 8601) | - - ---- - -## BuildConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **buildCommand** | `string` | optional | Build command (e.g., npm run build) | -| **outputDirectory** | `string` | optional | Output directory (e.g., .next, dist) | -| **installCommand** | `string` | optional | Install command (e.g., npm install, pnpm install) | -| **devCommand** | `string` | optional | Development command (e.g., npm run dev) | -| **nodeVersion** | `string` | optional | Node.js version (e.g., 18.x, 20.x) | -| **env** | `Record` | optional | Build environment variables | - - ---- - -## CdcConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable CDC | -| **method** | `Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>` | ✅ | CDC method | -| **slotName** | `string` | optional | Replication slot name (for log-based CDC) | -| **publicationName** | `string` | optional | Publication name (for PostgreSQL) | -| **startPosition** | `string` | optional | Starting position/LSN for CDC stream | -| **batchSize** | `number` | ✅ | CDC batch size | -| **pollIntervalMs** | `number` | ✅ | CDC polling interval in ms | - - ---- - -## DatabaseConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'database'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'postgresql' \| 'mysql' \| 'mariadb' \| 'mssql' \| 'oracle' \| 'mongodb' \| 'redis' \| 'cassandra' \| 'snowflake' \| 'bigquery' \| 'redshift' \| 'custom'>` | ✅ | Database provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **connectionConfig** | `{ host: string; port: number; database: string; username: string; … }` | ✅ | Database connection configuration | -| **poolConfig** | `{ min?: number; max?: number; idleTimeoutMs?: number; connectionTimeoutMs?: number; … }` | optional | Connection pool configuration | -| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | -| **tables** | `{ name: string; label: string; schema?: string; tableName: string; … }[]` | ✅ | Tables to sync | -| **cdcConfig** | `{ enabled?: boolean; method: Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>; slotName?: string; publicationName?: string; … }` | optional | CDC configuration | -| **readReplicaConfig** | `{ enabled?: boolean; hosts: { host: string; port: number; weight?: number }[] }` | optional | Read replica configuration | -| **queryTimeoutMs** | `number` | optional | Query timeout in ms | -| **enableQueryLogging** | `boolean` | optional | Enable SQL query logging | - - ---- - -## DatabasePoolConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **min** | `number` | ✅ | Minimum connections in pool | -| **max** | `number` | ✅ | Maximum connections in pool | -| **idleTimeoutMs** | `number` | ✅ | Idle connection timeout in ms | -| **connectionTimeoutMs** | `number` | ✅ | Connection establishment timeout in ms | -| **acquireTimeoutMs** | `number` | ✅ | Connection acquisition timeout in ms | -| **evictionRunIntervalMs** | `number` | ✅ | Connection eviction check interval in ms | -| **testOnBorrow** | `boolean` | ✅ | Test connection before use | - - ---- - -## DatabaseTable - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Table name in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **schema** | `string` | optional | Database schema name | -| **tableName** | `string` | ✅ | Actual table name in database | -| **primaryKey** | `string` | ✅ | Primary key column | -| **enabled** | `boolean` | optional | Enable sync for this table | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Table-specific field mappings | -| **whereClause** | `string` | optional | SQL WHERE clause for filtering | - - ---- - -## DeliveryGuarantee - -Message delivery guarantee - -### Allowed Values - -* `at_most_once` -* `at_least_once` -* `exactly_once` - - ---- - -## DeploymentConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **autoDeployment** | `boolean` | ✅ | Enable automatic deployments | -| **regions** | `Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]` | optional | Deployment regions | -| **enablePreview** | `boolean` | ✅ | Enable preview deployments | -| **previewComments** | `boolean` | ✅ | Post preview URLs in PR comments | -| **productionProtection** | `boolean` | ✅ | Require approval for production deployments | -| **deployHooks** | `{ name: string; url: string; branch?: string }[]` | optional | Deploy hooks | - - ---- - -## DlqConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable DLQ | -| **queueName** | `string` | ✅ | Dead letter queue/topic name | -| **maxRetries** | `number` | ✅ | Max retries before DLQ | -| **retryDelayMs** | `number` | ✅ | Retry delay in ms | - - ---- - -## DomainConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **domain** | `string` | ✅ | Domain name (e.g., app.example.com) | -| **httpsRedirect** | `boolean` | ✅ | Redirect HTTP to HTTPS | -| **customCertificate** | `{ cert: string; key: string; ca?: string }` | optional | Custom SSL certificate | -| **gitBranch** | `string` | optional | Git branch to deploy to this domain | - - ---- - -## EdgeFunctionConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Edge function name | -| **path** | `string` | ✅ | Function path (e.g., /api/*) | -| **regions** | `string[]` | optional | Specific regions for this function | -| **memoryLimit** | `integer` | ✅ | Memory limit in MB | -| **timeout** | `integer` | ✅ | Timeout in seconds | - - ---- - -## EnvironmentVariables - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Environment variable name | -| **value** | `string` | ✅ | Environment variable value | -| **target** | `Enum<'production' \| 'preview' \| 'development'>[]` | ✅ | Target environments | -| **isSecret** | `boolean` | ✅ | Encrypt this variable | -| **gitBranch** | `string` | optional | Specific git branch | - - ---- - -## FileAccessPattern - -File access pattern - -### Allowed Values - -* `public_read` -* `private` -* `authenticated_read` -* `bucket_owner_read` -* `bucket_owner_full` - - ---- - -## FileFilterConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **includePatterns** | `string[]` | optional | File patterns to include (glob) | -| **excludePatterns** | `string[]` | optional | File patterns to exclude (glob) | -| **minFileSize** | `number` | optional | Minimum file size in bytes | -| **maxFileSize** | `number` | optional | Maximum file size in bytes | -| **allowedExtensions** | `string[]` | optional | Allowed file extensions | -| **blockedExtensions** | `string[]` | optional | Blocked file extensions | - - ---- - -## FileMetadataConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **extractMetadata** | `boolean` | ✅ | Extract file metadata | -| **metadataFields** | `Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]` | optional | Metadata fields to extract | -| **customMetadata** | `Record` | optional | Custom metadata key-value pairs | - - ---- - -## FileStorageConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'file_storage'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'s3' \| 'azure_blob' \| 'gcs' \| 'dropbox' \| 'box' \| 'onedrive' \| 'google_drive' \| 'sharepoint' \| 'ftp' \| 'local' \| 'custom'>` | ✅ | File storage provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **storageConfig** | `{ endpoint?: string; region?: string; pathStyle?: boolean }` | optional | Storage configuration | -| **buckets** | `{ name: string; label: string; bucketName: string; region?: string; … }[]` | ✅ | Buckets/containers to sync | -| **metadataConfig** | `{ extractMetadata?: boolean; metadataFields?: Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]; customMetadata?: Record }` | optional | Metadata extraction configuration | -| **multipartConfig** | `{ enabled?: boolean; partSize?: number; maxConcurrentParts?: number; threshold?: number }` | optional | Multipart upload configuration | -| **versioningConfig** | `{ enabled?: boolean; maxVersions?: number; retentionDays?: number }` | optional | File versioning configuration | -| **encryption** | `{ enabled?: boolean; algorithm?: Enum<'AES256' \| 'aws:kms' \| 'custom'>; kmsKeyId?: string }` | optional | Encryption configuration | -| **lifecyclePolicy** | `{ enabled?: boolean; deleteAfterDays?: number; archiveAfterDays?: number }` | optional | Lifecycle policy | -| **contentProcessing** | `{ extractText?: boolean; generateThumbnails?: boolean; thumbnailSizes?: { width: number; height: number }[]; virusScan?: boolean }` | optional | Content processing configuration | -| **bufferSize** | `number` | optional | Buffer size in bytes | -| **transferAcceleration** | `boolean` | optional | Enable transfer acceleration | - - ---- - -## FileStorageProvider - -File storage provider type - -### Allowed Values - -* `s3` -* `azure_blob` -* `gcs` -* `dropbox` -* `box` -* `onedrive` -* `google_drive` -* `sharepoint` -* `ftp` -* `local` -* `custom` - - ---- - -## FileVersioningConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable file versioning | -| **maxVersions** | `number` | optional | Maximum versions to retain | -| **retentionDays** | `number` | optional | Version retention period in days | - - ---- - -## GitHubActionsWorkflow - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Workflow name | -| **path** | `string` | ✅ | Workflow file path (e.g., .github/workflows/ci.yml) | -| **enabled** | `boolean` | ✅ | Enable workflow | -| **triggers** | `Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]` | optional | Workflow triggers | -| **env** | `Record` | optional | Environment variables | -| **secrets** | `string[]` | optional | Required secrets | - - ---- - -## GitHubCommitConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **authorName** | `string` | optional | Commit author name | -| **authorEmail** | `string` | optional | Commit author email | -| **signCommits** | `boolean` | ✅ | Sign commits with GPG | -| **messageTemplate** | `string` | optional | Commit message template | -| **useConventionalCommits** | `boolean` | ✅ | Use conventional commits format | - - ---- - -## GitHubConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'github' \| 'github_enterprise'>` | ✅ | GitHub provider | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | optional | GitHub API base URL | -| **repositories** | `{ owner: string; name: string; defaultBranch?: string; autoMerge?: boolean; … }[]` | ✅ | Repositories to manage | -| **commitConfig** | `{ authorName?: string; authorEmail?: string; signCommits?: boolean; messageTemplate?: string; … }` | optional | Commit configuration | -| **pullRequestConfig** | `{ titleTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; bodyTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; defaultReviewers?: string[]; defaultAssignees?: string[]; … }` | optional | Pull request configuration | -| **workflows** | `{ name: string; path: string; enabled?: boolean; triggers?: Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]; … }[]` | optional | GitHub Actions workflows | -| **releaseConfig** | `{ tagPattern?: string; semanticVersioning?: boolean; autoReleaseNotes?: boolean; releaseNameTemplate?: string; … }` | optional | Release configuration | -| **issueTracking** | `{ enabled?: boolean; defaultLabels?: string[]; templatePaths?: string[]; autoAssign?: boolean; … }` | optional | Issue tracking configuration | -| **enableWebhooks** | `boolean` | optional | Enable GitHub webhooks | -| **webhookEvents** | `Enum<'push' \| 'pull_request' \| 'issues' \| 'issue_comment' \| 'release' \| 'workflow_run' \| 'deployment' \| 'deployment_status' \| 'check_run' \| 'check_suite' \| 'status'>[]` | optional | Webhook events to subscribe to | - - ---- - -## GitHubIssueTracking - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable issue tracking | -| **defaultLabels** | `string[]` | optional | Default issue labels | -| **templatePaths** | `string[]` | optional | Issue template paths | -| **autoAssign** | `boolean` | ✅ | Auto-assign issues | -| **autoCloseStale** | `{ enabled: boolean; daysBeforeStale: integer; daysBeforeClose: integer; staleLabel: string }` | optional | Auto-close stale issues configuration | - - ---- - -## GitHubProvider - -GitHub provider type - -### Allowed Values - -* `github` -* `github_enterprise` - - ---- - -## GitHubPullRequestConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **titleTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR title template — supports `{{var}`} interpolation | -| **bodyTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR body template — supports `{{var}`} interpolation | -| **defaultReviewers** | `string[]` | optional | Default reviewers (usernames) | -| **defaultAssignees** | `string[]` | optional | Default assignees (usernames) | -| **defaultLabels** | `string[]` | optional | Default labels | -| **draftByDefault** | `boolean` | optional | Create draft PRs by default | -| **deleteHeadBranch** | `boolean` | optional | Delete head branch after merge | - - ---- - -## GitHubReleaseConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **tagPattern** | `string` | ✅ | Tag name pattern (e.g., v*, release/*) | -| **semanticVersioning** | `boolean` | ✅ | Use semantic versioning | -| **autoReleaseNotes** | `boolean` | ✅ | Generate release notes automatically | -| **releaseNameTemplate** | `string` | optional | Release name template | -| **preReleasePattern** | `string` | optional | Pre-release pattern (e.g., *-alpha, *-beta) | -| **draftByDefault** | `boolean` | ✅ | Create draft releases by default | - - ---- - -## GitHubRepository - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **owner** | `string` | ✅ | Repository owner (organization or username) | -| **name** | `string` | ✅ | Repository name | -| **defaultBranch** | `string` | ✅ | Default branch name | -| **autoMerge** | `boolean` | ✅ | Enable auto-merge for pull requests | -| **branchProtection** | `{ requiredReviewers: integer; requireStatusChecks: boolean; enforceAdmins: boolean; allowForcePushes: boolean; … }` | optional | Branch protection configuration | -| **topics** | `string[]` | optional | Repository topics | - - ---- - -## GitRepositoryConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'github' \| 'gitlab' \| 'bitbucket'>` | ✅ | Git provider | -| **repo** | `string` | ✅ | Repository identifier (e.g., owner/repo) | -| **productionBranch** | `string` | ✅ | Production branch name | -| **autoDeployProduction** | `boolean` | ✅ | Auto-deploy production branch | -| **autoDeployPreview** | `boolean` | ✅ | Auto-deploy preview branches | - - ---- - -## MessageFormat - -Message format/serialization - -### Allowed Values - -* `json` -* `xml` -* `protobuf` -* `avro` -* `text` -* `binary` - - ---- - -## MessageQueueConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'message_queue'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'rabbitmq' \| 'kafka' \| 'redis_pubsub' \| 'redis_streams' \| 'aws_sqs' \| 'aws_sns' \| 'google_pubsub' \| 'azure_service_bus' \| 'azure_event_hubs' \| 'nats' \| 'pulsar' \| 'activemq' \| 'custom'>` | ✅ | Message queue provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **brokerConfig** | `{ brokers: string[]; clientId?: string; connectionTimeoutMs?: number; requestTimeoutMs?: number }` | ✅ | Broker connection configuration | -| **topics** | `{ name: string; label: string; topicName: string; enabled?: boolean; … }[]` | ✅ | Topics/queues to sync | -| **deliveryGuarantee** | `Enum<'at_most_once' \| 'at_least_once' \| 'exactly_once'>` | optional | Message delivery guarantee | -| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | -| **saslConfig** | `{ mechanism: Enum<'plain' \| 'scram-sha-256' \| 'scram-sha-512' \| 'aws'>; username?: string; password?: string }` | optional | SASL authentication configuration | -| **schemaRegistry** | `{ url: string; auth?: object }` | optional | Schema registry configuration | -| **preserveOrder** | `boolean` | optional | Preserve message ordering | -| **enableMetrics** | `boolean` | optional | Enable message queue metrics | -| **enableTracing** | `boolean` | optional | Enable distributed tracing | - - ---- - -## ProducerConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable producer | -| **acks** | `Enum<'0' \| '1' \| 'all'>` | ✅ | Acknowledgment level | -| **compressionType** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>` | ✅ | Compression type | -| **batchSize** | `number` | ✅ | Batch size in bytes | -| **lingerMs** | `number` | ✅ | Linger time in ms | -| **maxInFlightRequests** | `number` | ✅ | Max in-flight requests | -| **idempotence** | `boolean` | ✅ | Enable idempotent producer | -| **transactional** | `boolean` | ✅ | Enable transactional producer | -| **transactionTimeoutMs** | `number` | optional | Transaction timeout in ms | - - ---- - -## SaasConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'salesforce' \| 'hubspot' \| 'stripe' \| 'shopify' \| 'zendesk' \| 'intercom' \| 'mailchimp' \| 'slack' \| 'microsoft_dynamics' \| 'servicenow' \| 'netsuite' \| 'custom'>` | ✅ | SaaS provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | ✅ | API base URL | -| **apiVersion** | `{ version: string; isDefault?: boolean; deprecationDate?: string; sunsetDate?: string }` | optional | API version configuration | -| **objectTypes** | `{ name: string; label: string; apiName: string; enabled?: boolean; … }[]` | ✅ | Syncable object types | -| **oauthSettings** | `{ scopes: string[]; refreshTokenUrl?: string; revokeTokenUrl?: string; autoRefresh?: boolean }` | optional | OAuth-specific configuration | -| **paginationConfig** | `{ type?: Enum<'cursor' \| 'offset' \| 'page'>; defaultPageSize?: number; maxPageSize?: number }` | optional | Pagination configuration | -| **sandboxConfig** | `{ enabled?: boolean; baseUrl?: string }` | optional | Sandbox environment configuration | -| **customHeaders** | `Record` | optional | Custom HTTP headers for all requests | - - ---- - -## SaasObjectType - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Object type name (snake_case) | -| **label** | `string` | ✅ | Display label | -| **apiName** | `string` | ✅ | API name in external system | -| **enabled** | `boolean` | optional | Enable sync for this object | -| **supportsCreate** | `boolean` | optional | Supports record creation | -| **supportsUpdate** | `boolean` | optional | Supports record updates | -| **supportsDelete** | `boolean` | optional | Supports record deletion | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Object-specific field mappings | - - ---- - -## SaasProvider - -SaaS provider type - -### Allowed Values - -* `salesforce` -* `hubspot` -* `stripe` -* `shopify` -* `zendesk` -* `intercom` -* `mailchimp` -* `slack` -* `microsoft_dynamics` -* `servicenow` -* `netsuite` -* `custom` - - ---- - -## SslConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable SSL/TLS | -| **rejectUnauthorized** | `boolean` | ✅ | Reject unauthorized certificates | -| **ca** | `string` | optional | Certificate Authority certificate | -| **cert** | `string` | optional | Client certificate | -| **key** | `string` | optional | Client private key | - - ---- - -## StorageBucket - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Bucket identifier in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **bucketName** | `string` | ✅ | Actual bucket/container name in storage system | -| **region** | `string` | optional | Storage region | -| **enabled** | `boolean` | ✅ | Enable sync for this bucket | -| **prefix** | `string` | optional | Prefix/path within bucket | -| **accessPattern** | `Enum<'public_read' \| 'private' \| 'authenticated_read' \| 'bucket_owner_read' \| 'bucket_owner_full'>` | optional | Access pattern | -| **fileFilters** | `{ includePatterns?: string[]; excludePatterns?: string[]; minFileSize?: number; maxFileSize?: number; … }` | optional | File filter configuration | - - ---- - -## TopicQueue - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Topic/queue identifier in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **topicName** | `string` | ✅ | Actual topic/queue name in message queue system | -| **enabled** | `boolean` | ✅ | Enable sync for this topic/queue | -| **mode** | `Enum<'consumer' \| 'producer' \| 'both'>` | ✅ | Consumer, producer, or both | -| **messageFormat** | `Enum<'json' \| 'xml' \| 'protobuf' \| 'avro' \| 'text' \| 'binary'>` | ✅ | Message format/serialization | -| **partitions** | `number` | optional | Number of partitions (for Kafka) | -| **replicationFactor** | `number` | optional | Replication factor (for Kafka) | -| **consumerConfig** | `{ enabled: boolean; consumerGroup?: string; concurrency: number; prefetchCount: number; … }` | optional | Consumer-specific configuration | -| **producerConfig** | `{ enabled: boolean; acks: Enum<'0' \| '1' \| 'all'>; compressionType: Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>; batchSize: number; … }` | optional | Producer-specific configuration | -| **dlqConfig** | `{ enabled: boolean; queueName: string; maxRetries: number; retryDelayMs: number }` | optional | Dead letter queue configuration | -| **routingKey** | `string` | optional | Routing key pattern | -| **messageFilter** | `{ headers?: Record; attributes?: Record }` | optional | Message filter criteria | - - ---- - -## VercelConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'vercel'>` | ✅ | Vercel provider | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | optional | Vercel API base URL | -| **team** | `{ teamId?: string; teamName?: string }` | optional | Vercel team configuration | -| **projects** | `{ name: string; framework?: Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>; gitRepository?: object; buildConfig?: object; … }[]` | ✅ | Vercel projects | -| **monitoring** | `{ enableWebAnalytics?: boolean; enableSpeedInsights?: boolean; logDrains?: { name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[] }` | optional | Monitoring configuration | -| **enableWebhooks** | `boolean` | optional | Enable Vercel webhooks | -| **webhookEvents** | `Enum<'deployment.created' \| 'deployment.succeeded' \| 'deployment.failed' \| 'deployment.ready' \| 'deployment.error' \| 'deployment.canceled' \| 'deployment-checks-completed' \| 'deployment-prepared' \| 'project.created' \| 'project.removed'>[]` | optional | Webhook events to subscribe to | - - ---- - -## VercelFramework - -Frontend framework - -### Allowed Values - -* `nextjs` -* `react` -* `vue` -* `nuxtjs` -* `gatsby` -* `remix` -* `astro` -* `sveltekit` -* `solid` -* `angular` -* `static` -* `other` - - ---- - -## VercelMonitoring - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enableWebAnalytics** | `boolean` | ✅ | Enable Vercel Web Analytics | -| **enableSpeedInsights** | `boolean` | ✅ | Enable Vercel Speed Insights | -| **logDrains** | `{ name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[]` | optional | Log drains configuration | - - ---- - -## VercelProject - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Vercel project name | -| **framework** | `Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>` | optional | Frontend framework | -| **gitRepository** | `{ type: Enum<'github' \| 'gitlab' \| 'bitbucket'>; repo: string; productionBranch: string; autoDeployProduction: boolean; … }` | optional | Git repository configuration | -| **buildConfig** | `{ buildCommand?: string; outputDirectory?: string; installCommand?: string; devCommand?: string; … }` | optional | Build configuration | -| **deploymentConfig** | `{ autoDeployment: boolean; regions?: Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]; enablePreview: boolean; previewComments: boolean; … }` | optional | Deployment configuration | -| **domains** | `{ domain: string; httpsRedirect: boolean; customCertificate?: object; gitBranch?: string }[]` | optional | Custom domains | -| **environmentVariables** | `{ key: string; value: string; target: Enum<'production' \| 'preview' \| 'development'>[]; isSecret: boolean; … }[]` | optional | Environment variables | -| **edgeFunctions** | `{ name: string; path: string; regions?: string[]; memoryLimit: integer; … }[]` | optional | Edge functions | -| **rootDirectory** | `string` | optional | Root directory (for monorepos) | - - ---- - -## VercelProvider - -Vercel provider type - -### Allowed Values - -* `vercel` - - ---- - -## VercelTeam - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **teamId** | `string` | optional | Team ID or slug | -| **teamName** | `string` | optional | Team name | - - ---- - diff --git a/content/docs/references/integration/object-storage.mdx b/content/docs/references/integration/object-storage.mdx deleted file mode 100644 index c2f2e95aca..0000000000 --- a/content/docs/references/integration/object-storage.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Object Storage -description: Object Storage protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/integration/object-storage.zod.ts` - - -## TypeScript Usage - -```typescript -import { MultipartUploadConfig } from '@objectstack/spec/integration'; -import type { MultipartUploadConfig } from '@objectstack/spec/integration'; - -// Validate data -const result = MultipartUploadConfig.parse(data); -``` - ---- - -## MultipartUploadConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable multipart uploads | -| **partSize** | `number` | ✅ | Part size in bytes (min 5MB) | -| **maxConcurrentParts** | `number` | ✅ | Maximum concurrent part uploads | -| **threshold** | `number` | ✅ | File size threshold for multipart upload in bytes | - - ---- - diff --git a/content/docs/references/integration/offline.mdx b/content/docs/references/integration/offline.mdx index 75736d3b07..5ddceaf3ee 100644 --- a/content/docs/references/integration/offline.mdx +++ b/content/docs/references/integration/offline.mdx @@ -5,10 +5,6 @@ description: Offline protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/offline.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/tenant.mdx b/content/docs/references/integration/tenant.mdx deleted file mode 100644 index d7a420d1ff..0000000000 --- a/content/docs/references/integration/tenant.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Tenant -description: Tenant protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/integration/tenant.zod.ts` - - -## TypeScript Usage - -```typescript -import { DatabaseProvider } from '@objectstack/spec/integration'; -import type { DatabaseProvider } from '@objectstack/spec/integration'; - -// Validate data -const result = DatabaseProvider.parse(data); -``` - ---- - -## DatabaseProvider - -Database provider type - -### Allowed Values - -* `postgresql` -* `mysql` -* `mariadb` -* `mssql` -* `oracle` -* `mongodb` -* `redis` -* `cassandra` -* `snowflake` -* `bigquery` -* `redshift` -* `custom` - - ---- - diff --git a/content/docs/references/kernel/events-bus.mdx b/content/docs/references/kernel/events-bus.mdx new file mode 100644 index 0000000000..872fd0a2e0 --- /dev/null +++ b/content/docs/references/kernel/events-bus.mdx @@ -0,0 +1,64 @@ +--- +title: Events Bus +description: Events Bus protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Bus Configuration Schema + +Complete configuration for the event bus system + +@example + +\{ + +"persistence": \{ "enabled": true, "retention": 365 \}, + +"queue": \{ "concurrency": 20 \}, + +"eventSourcing": \{ "enabled": true \}, + +"webhooks": [], + +"messageQueue": \{ "provider": "kafka", "topic": "events" \}, + +"realtime": \{ "enabled": true, "protocol": "websocket" \} + +\} + + +**Source:** `packages/spec/src/kernel/events/bus.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventBusConfig } from '@objectstack/spec/kernel'; +import type { EventBusConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventBusConfig.parse(data); +``` + +--- + +## EventBusConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | +| **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | +| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | +| **replay** | `{ enabled: boolean }` | optional | Event replay configuration | +| **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | +| **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | +| **realtime** | `{ enabled: boolean; protocol: Enum<'websocket' \| 'sse' \| 'long-polling'>; eventPattern: string; userFilter: boolean; … }` | optional | Real-time notification configuration | +| **eventTypes** | `{ name: string; version: string; schema?: any; description?: string; … }[]` | optional | Event type definitions | +| **handlers** | `{ id?: string; eventName: string; handler: any; priority: integer; … }[]` | optional | Global event handlers | + + +--- + diff --git a/content/docs/references/kernel/events-core.mdx b/content/docs/references/kernel/events-core.mdx new file mode 100644 index 0000000000..83478875fb --- /dev/null +++ b/content/docs/references/kernel/events-core.mdx @@ -0,0 +1,90 @@ +--- +title: Events Core +description: Events Core protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Priority Enum + +Priority levels for event processing + +Lower numbers = higher priority + + +**Source:** `packages/spec/src/kernel/events/core.zod.ts` + + +## TypeScript Usage + +```typescript +import { Event, EventMetadata, EventPriority, EventTypeDefinition } from '@objectstack/spec/kernel'; +import type { Event, EventMetadata, EventPriority, EventTypeDefinition } from '@objectstack/spec/kernel'; + +// Validate data +const result = Event.parse(data); +``` + +--- + +## Event + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique event identifier | +| **name** | `string` | ✅ | Event name (lowercase with dots, e.g., user.created, order.paid) | +| **payload** | `any` | ✅ | Event payload schema | +| **metadata** | `{ source: string; timestamp: string; userId?: string; tenantId?: string; … }` | ✅ | Event metadata | + + +--- + +## EventMetadata + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **source** | `string` | ✅ | Event source (e.g., plugin name, system component) | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when event was created | +| **userId** | `string` | optional | User who triggered the event | +| **tenantId** | `string` | optional | Tenant identifier for multi-tenant systems | +| **correlationId** | `string` | optional | Correlation ID for event tracing | +| **causationId** | `string` | optional | ID of the event that caused this event | +| **priority** | `Enum<'critical' \| 'high' \| 'normal' \| 'low' \| 'background'>` | ✅ | Event priority | +| **cluster** | `{ scope: Enum<'local' \| 'cluster' \| 'tenant'>; deliverySemantics?: Enum<'best-effort' \| 'at-least-once' \| 'exactly-once'>; partitionKey?: string }` | optional | Per-emit cluster routing & delivery options. See cluster-semantics.mdx §4. | + + +--- + +## EventPriority + +### Allowed Values + +* `critical` +* `high` +* `normal` +* `low` +* `background` + + +--- + +## EventTypeDefinition + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Event type name (lowercase with dots) | +| **version** | `string` | ✅ | Event schema version | +| **schema** | `any` | optional | JSON Schema for event payload validation | +| **description** | `string` | optional | Event type description | +| **deprecated** | `boolean` | ✅ | Whether this event type is deprecated | +| **tags** | `string[]` | optional | Event type tags | + + +--- + diff --git a/content/docs/references/kernel/events-dlq.mdx b/content/docs/references/kernel/events-dlq.mdx new file mode 100644 index 0000000000..3c8affddee --- /dev/null +++ b/content/docs/references/kernel/events-dlq.mdx @@ -0,0 +1,61 @@ +--- +title: Events Dlq +description: Events Dlq protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Dead Letter Queue Entry Schema + +Represents a failed event in the dead letter queue + + +**Source:** `packages/spec/src/kernel/events/dlq.zod.ts` + + +## TypeScript Usage + +```typescript +import { DeadLetterQueueEntry, EventLogEntry } from '@objectstack/spec/kernel'; +import type { DeadLetterQueueEntry, EventLogEntry } from '@objectstack/spec/kernel'; + +// Validate data +const result = DeadLetterQueueEntry.parse(data); +``` + +--- + +## DeadLetterQueueEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique entry identifier | +| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | Original event | +| **error** | `{ message: string; stack?: string; code?: string }` | ✅ | Failure details | +| **retries** | `integer` | ✅ | Number of retry attempts | +| **firstFailedAt** | `string` | ✅ | When event first failed | +| **lastFailedAt** | `string` | ✅ | When event last failed | +| **failedHandler** | `string` | optional | Handler ID that failed | + + +--- + +## EventLogEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique log entry identifier | +| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | The event | +| **status** | `Enum<'pending' \| 'processing' \| 'completed' \| 'failed'>` | ✅ | Processing status | +| **handlersExecuted** | `{ handlerId: string; status: Enum<'success' \| 'failed' \| 'timeout'>; durationMs?: integer; error?: string }[]` | optional | Handlers that processed this event | +| **receivedAt** | `string` | ✅ | When event was received | +| **processedAt** | `string` | optional | When event was processed | +| **totalDurationMs** | `integer` | optional | Total processing time | + + +--- + diff --git a/content/docs/references/kernel/events-handlers.mdx b/content/docs/references/kernel/events-handlers.mdx new file mode 100644 index 0000000000..bd3f67001f --- /dev/null +++ b/content/docs/references/kernel/events-handlers.mdx @@ -0,0 +1,72 @@ +--- +title: Events Handlers +description: Events Handlers protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Handler Schema + +Defines how to handle a specific event + + +**Source:** `packages/spec/src/kernel/events/handlers.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventHandler, EventPersistence, EventRoute } from '@objectstack/spec/kernel'; +import type { EventHandler, EventPersistence, EventRoute } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventHandler.parse(data); +``` + +--- + +## EventHandler + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique handler identifier | +| **eventName** | `string` | ✅ | Name of event to handle (supports wildcards like user.*) | +| **handler** | `any` | ✅ | Handler function | +| **priority** | `integer` | ✅ | Execution priority (lower numbers execute first) | +| **async** | `boolean` | ✅ | Execute in background (true) or block (false) | +| **retry** | `{ maxRetries: integer; backoffMs: integer; backoffMultiplier: number }` | optional | Retry policy for failed handlers | +| **timeoutMs** | `integer` | optional | Handler timeout in milliseconds | +| **filter** | `any` | optional | Optional filter to determine if handler should execute | + + +--- + +## EventPersistence + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable event persistence | +| **retention** | `integer` | ✅ | Days to retain persisted events | +| **filter** | `any` | optional | Optional filter function to select which events to persist | +| **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | ✅ | Storage backend for persisted events | + + +--- + +## EventRoute + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **from** | `string` | ✅ | Source event pattern (supports wildcards, e.g., user.* or *.created) | +| **to** | `string[]` | ✅ | Target event names to route to | +| **transform** | `any` | optional | Optional function to transform payload | + + +--- + diff --git a/content/docs/references/kernel/events-integrations.mdx b/content/docs/references/kernel/events-integrations.mdx new file mode 100644 index 0000000000..8757af711e --- /dev/null +++ b/content/docs/references/kernel/events-integrations.mdx @@ -0,0 +1,97 @@ +--- +title: Events Integrations +description: Events Integrations protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Webhook Configuration Schema + +Configuration for sending events to webhooks + +@example + +\{ + +"eventPattern": "order.*", + +"url": "https://api.example.com/webhooks/orders", + +"method": "POST", + +"headers": \{ "Authorization": "Bearer token" \} + +\} + + +**Source:** `packages/spec/src/kernel/events/integrations.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventMessageQueueConfig, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; +import type { EventMessageQueueConfig, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventMessageQueueConfig.parse(data); +``` + +--- + +## EventMessageQueueConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>` | ✅ | Message queue provider | +| **topic** | `string` | ✅ | Topic or queue name | +| **eventPattern** | `string` | ✅ | Event name pattern to publish (supports wildcards) | +| **partitionKey** | `string` | optional | JSON path for partition key (e.g., "metadata.tenantId") | +| **format** | `Enum<'json' \| 'avro' \| 'protobuf'>` | ✅ | Message serialization format | +| **includeMetadata** | `boolean` | ✅ | Include event metadata in message | +| **compression** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4'>` | ✅ | Message compression | +| **batchSize** | `integer` | ✅ | Batch size for publishing | +| **flushIntervalMs** | `integer` | ✅ | Flush interval for batching | + + +--- + +## EventWebhookConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique webhook identifier | +| **eventPattern** | `string` | ✅ | Event name pattern (supports wildcards) | +| **url** | `string` | ✅ | Webhook endpoint URL | +| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>` | ✅ | HTTP method | +| **headers** | `Record` | optional | HTTP headers | +| **authentication** | `{ type: Enum<'none' \| 'bearer' \| 'basic' \| 'api-key'>; credentials?: Record }` | optional | Authentication configuration | +| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Retry policy | +| **timeoutMs** | `integer` | ✅ | Request timeout in milliseconds | +| **transform** | `any` | optional | Transform event before sending | +| **enabled** | `boolean` | ✅ | Whether webhook is enabled | + + +--- + +## RealTimeNotificationConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable real-time notifications | +| **protocol** | `Enum<'websocket' \| 'sse' \| 'long-polling'>` | ✅ | Real-time protocol | +| **eventPattern** | `string` | ✅ | Event pattern to broadcast | +| **userFilter** | `boolean` | ✅ | Filter events by user | +| **tenantFilter** | `boolean` | ✅ | Filter events by tenant | +| **channels** | `{ name: string; eventPattern: string; filter?: any }[]` | optional | Named channels for event broadcasting | +| **rateLimit** | `{ maxEventsPerSecond: integer; windowMs: integer }` | optional | Rate limiting configuration | + + +--- + diff --git a/content/docs/references/kernel/events-queue.mdx b/content/docs/references/kernel/events-queue.mdx new file mode 100644 index 0000000000..d262c5db13 --- /dev/null +++ b/content/docs/references/kernel/events-queue.mdx @@ -0,0 +1,92 @@ +--- +title: Events Queue +description: Events Queue protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Queue Configuration Schema + +Configuration for async event processing queue + +@example + +\{ + +"name": "event_queue", + +"concurrency": 10, + +"retryPolicy": \{ + +"maxRetries": 3, + +"backoffStrategy": "exponential" + +\} + +\} + + +**Source:** `packages/spec/src/kernel/events/queue.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventQueueConfig, EventReplayConfig, EventSourcingConfig } from '@objectstack/spec/kernel'; +import type { EventQueueConfig, EventReplayConfig, EventSourcingConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventQueueConfig.parse(data); +``` + +--- + +## EventQueueConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Event queue name | +| **concurrency** | `integer` | ✅ | Max concurrent event handlers | +| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Default retry policy for events | +| **deadLetterQueue** | `string` | optional | Dead letter queue name for failed events | +| **priorityEnabled** | `boolean` | ✅ | Process events based on priority | + + +--- + +## EventReplayConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromTimestamp** | `string` | ✅ | Start timestamp for replay (ISO 8601) | +| **toTimestamp** | `string` | optional | End timestamp for replay (ISO 8601) | +| **eventTypes** | `string[]` | optional | Event types to replay (empty = all) | +| **filters** | `Record` | optional | Additional filters for event selection | +| **speed** | `number` | ✅ | Replay speed multiplier (1 = real-time) | +| **targetHandlers** | `string[]` | optional | Handler IDs to execute (empty = all) | + + +--- + +## EventSourcingConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable event sourcing | +| **snapshotInterval** | `integer` | ✅ | Create snapshot every N events | +| **snapshotRetention** | `integer` | ✅ | Number of snapshots to retain | +| **retention** | `integer` | ✅ | Days to retain events | +| **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | +| **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | + + +--- + diff --git a/content/docs/references/kernel/index.mdx b/content/docs/references/kernel/index.mdx index c4d3f3c9a0..5eb897a29f 100644 --- a/content/docs/references/kernel/index.mdx +++ b/content/docs/references/kernel/index.mdx @@ -10,6 +10,12 @@ This section contains all protocol schemas for the kernel layer of ObjectStack. + + + + + + diff --git a/content/docs/references/kernel/meta.json b/content/docs/references/kernel/meta.json index a44766a69c..5bd1cc6acc 100644 --- a/content/docs/references/kernel/meta.json +++ b/content/docs/references/kernel/meta.json @@ -27,12 +27,16 @@ "execution-context", "metadata-customization", "metadata-loader", - "metadata-persistence", "metadata-plugin", "metadata-protection", - "misc", "service-registry", "startup-orchestrator", - "state-machine" + "---More---", + "events-bus", + "events-core", + "events-dlq", + "events-handlers", + "events-integrations", + "events-queue" ] } \ No newline at end of file diff --git a/content/docs/references/kernel/metadata-loader.mdx b/content/docs/references/kernel/metadata-loader.mdx index 1785ddb868..06aa9caf3f 100644 --- a/content/docs/references/kernel/metadata-loader.mdx +++ b/content/docs/references/kernel/metadata-loader.mdx @@ -5,13 +5,11 @@ description: Metadata Loader protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -# Metadata Loader Protocol +# Metadata Manager Configuration -Defines the standard interface for loading and saving metadata in ObjectStack. +How the runtime `MetadataManager` is wired: which datasource backs `sys_metadata`, what to fall back to when that datasource is unreachable, cache / watch / validation settings, and the persistence write gates. -This protocol enables consistent metadata operations across different storage backends - -(filesystem, HTTP, S3, databases) and serialization formats (JSON, YAML, TypeScript). +The loader and watch *envelope* types (`MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataWatchEvent`, `MetadataLoaderContract`, …) are NOT here — they live in `@objectstack/spec/system` (`system/metadata-persistence.zod`), which is their single source. **Source:** `packages/spec/src/kernel/metadata-loader.zod.ts` @@ -50,7 +48,7 @@ const result = MetadataFallbackStrategy.parse(data); | **tableName** | `string` | ✅ | Database table name for metadata storage | | **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | ✅ | Fallback strategy when datasource is unavailable | | **rootDir** | `string` | optional | Root directory path | -| **formats** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>[]` | ✅ | Enabled formats | +| **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | ✅ | Enabled formats | | **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | | **watch** | `boolean` | ✅ | Enable file watching | | **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options | diff --git a/content/docs/references/kernel/metadata-persistence.mdx b/content/docs/references/kernel/metadata-persistence.mdx deleted file mode 100644 index 9c6c423978..0000000000 --- a/content/docs/references/kernel/metadata-persistence.mdx +++ /dev/null @@ -1,200 +0,0 @@ ---- -title: Metadata Persistence -description: Metadata Persistence protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/kernel/metadata-persistence.zod.ts` - - -## TypeScript Usage - -```typescript -import { MetadataCollectionInfo, MetadataExportOptions, MetadataFormat, MetadataImportOptions, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/kernel'; -import type { MetadataCollectionInfo, MetadataExportOptions, MetadataFormat, MetadataImportOptions, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/kernel'; - -// Validate data -const result = MetadataCollectionInfo.parse(data); -``` - ---- - -## MetadataCollectionInfo - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | Collection type | -| **count** | `integer` | ✅ | Number of items | -| **formats** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>[]` | ✅ | Formats in collection | -| **totalSize** | `integer` | optional | Total size in bytes | -| **lastModified** | `string` | optional | Last modification date | -| **location** | `string` | optional | Collection location | - - ---- - -## MetadataExportOptions - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **output** | `string` | ✅ | Output file path | -| **format** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>` | optional | Export format | -| **filter** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Filter items to export (CEL) | -| **includeStats** | `boolean` | optional | Include metadata statistics | -| **compress** | `boolean` | optional | Compress output (gzip) | -| **prettify** | `boolean` | optional | Pretty print output | - - ---- - -## MetadataFormat - -### Allowed Values - -* `json` -* `yaml` -* `typescript` -* `javascript` - - ---- - -## MetadataImportOptions - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **conflictResolution** | `Enum<'skip' \| 'overwrite' \| 'merge' \| 'fail'>` | ✅ | How to handle existing items | -| **validate** | `boolean` | ✅ | Validate before import | -| **dryRun** | `boolean` | ✅ | Simulate import without saving | -| **continueOnError** | `boolean` | ✅ | Continue if validation fails | -| **transform** | `string` | optional | Transform items before import | - - ---- - -## MetadataLoadOptions - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **patterns** | `string[]` | optional | File glob patterns | -| **ifNoneMatch** | `string` | optional | ETag for conditional request | -| **ifModifiedSince** | `string` | optional | Only load if modified after this date | -| **validate** | `boolean` | optional | Validate against schema | -| **useCache** | `boolean` | optional | Enable caching | -| **filter** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Filter predicate (CEL) | -| **limit** | `integer` | optional | Maximum items to load | -| **recursive** | `boolean` | optional | Search subdirectories | - - ---- - -## MetadataLoadResult - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **data** | `any \| null` | ✅ | Loaded metadata | -| **fromCache** | `boolean` | ✅ | Loaded from cache | -| **notModified** | `boolean` | ✅ | Not modified since last request | -| **etag** | `string` | optional | Entity tag | -| **stats** | `{ size: integer; modifiedAt: string; etag: string; format: Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>; … }` | optional | Metadata statistics | -| **loadTime** | `number` | optional | Load duration in ms | - - ---- - -## MetadataLoaderContract - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Loader identifier | -| **protocol** | `Enum<'file:' \| 'http:' \| 's3:' \| 'datasource:' \| 'memory:'>` | ✅ | Protocol identifier | -| **capabilities** | `{ read: boolean; write: boolean; watch: boolean; list: boolean }` | ✅ | Loader capabilities | -| **supportedFormats** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>[]` | ✅ | Supported formats | -| **supportsWatch** | `boolean` | ✅ | Supports file watching | -| **supportsWrite** | `boolean` | ✅ | Supports write operations | -| **supportsCache** | `boolean` | ✅ | Supports caching | - - ---- - -## MetadataSaveOptions - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **format** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>` | ✅ | Output format | -| **prettify** | `boolean` | ✅ | Format with indentation | -| **indent** | `integer` | ✅ | Indentation spaces | -| **sortKeys** | `boolean` | ✅ | Sort object keys | -| **includeDefaults** | `boolean` | ✅ | Include default values | -| **backup** | `boolean` | ✅ | Create backup file | -| **overwrite** | `boolean` | ✅ | Overwrite existing file | -| **atomic** | `boolean` | ✅ | Use atomic write operation | -| **path** | `string` | optional | Custom output path | - - ---- - -## MetadataSaveResult - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Save successful | -| **path** | `string` | ✅ | Output path | -| **etag** | `string` | optional | Generated entity tag | -| **size** | `integer` | optional | File size | -| **saveTime** | `number` | optional | Save duration in ms | -| **backupPath** | `string` | optional | Backup file path | - - ---- - -## MetadataStats - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **size** | `integer` | ✅ | File size in bytes | -| **modifiedAt** | `string` | ✅ | Last modified date | -| **etag** | `string` | ✅ | Entity tag for cache validation | -| **format** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>` | ✅ | Serialization format | -| **path** | `string` | optional | File system path | -| **metadata** | `Record` | optional | Provider-specific metadata | - - ---- - -## MetadataWatchEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'added' \| 'changed' \| 'deleted'>` | ✅ | Event type | -| **metadataType** | `string` | ✅ | Type of metadata | -| **name** | `string` | ✅ | Item identifier | -| **path** | `string` | ✅ | File path | -| **data** | `any` | optional | Item data | -| **timestamp** | `string` | ✅ | Event timestamp | - - ---- - diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 976eea9b4a..84e932554f 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -57,11 +57,11 @@ cohesive plugin that "takes over" the entire platform's metadata management: ## References -- [kernel/metadata-loader.zod.ts](/docs/references/kernel/metadata-loader) — Storage backend protocol +- [kernel/metadata-loader.zod.ts](/docs/references/kernel/metadata-loader) — MetadataManager wiring (datasource, cache, write gates) - [kernel/metadata-customization.zod.ts](/docs/references/kernel/metadata-customization) — Overlay/merge protocol -- [system/metadata-persistence.zod.ts](/docs/references/system/metadata-persistence) — Database record format +- [system/metadata-persistence.zod.ts](/docs/references/system/metadata-persistence) — Database record format + loader/watch envelope types - contracts/metadata-service.ts — Service interface diff --git a/content/docs/references/kernel/misc.mdx b/content/docs/references/kernel/misc.mdx deleted file mode 100644 index 29792ea516..0000000000 --- a/content/docs/references/kernel/misc.mdx +++ /dev/null @@ -1,271 +0,0 @@ ---- -title: Misc -description: Misc protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/kernel/misc.zod.ts` - - -## TypeScript Usage - -```typescript -import { DeadLetterQueueEntry, EventBusConfig, EventHandler, EventLogEntry, EventMessageQueueConfig, EventMetadata, EventPersistence, EventPriority, EventQueueConfig, EventReplayConfig, EventRoute, EventSourcingConfig, EventTypeDefinition, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; -import type { DeadLetterQueueEntry, EventBusConfig, EventHandler, EventLogEntry, EventMessageQueueConfig, EventMetadata, EventPersistence, EventPriority, EventQueueConfig, EventReplayConfig, EventRoute, EventSourcingConfig, EventTypeDefinition, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; - -// Validate data -const result = DeadLetterQueueEntry.parse(data); -``` - ---- - -## DeadLetterQueueEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique entry identifier | -| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | Original event | -| **error** | `{ message: string; stack?: string; code?: string }` | ✅ | Failure details | -| **retries** | `integer` | ✅ | Number of retry attempts | -| **firstFailedAt** | `string` | ✅ | When event first failed | -| **lastFailedAt** | `string` | ✅ | When event last failed | -| **failedHandler** | `string` | optional | Handler ID that failed | - - ---- - -## EventBusConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | -| **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | -| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | -| **replay** | `{ enabled: boolean }` | optional | Event replay configuration | -| **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | -| **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | -| **realtime** | `{ enabled: boolean; protocol: Enum<'websocket' \| 'sse' \| 'long-polling'>; eventPattern: string; userFilter: boolean; … }` | optional | Real-time notification configuration | -| **eventTypes** | `{ name: string; version: string; schema?: any; description?: string; … }[]` | optional | Event type definitions | -| **handlers** | `{ id?: string; eventName: string; handler: any; priority: integer; … }[]` | optional | Global event handlers | - - ---- - -## EventHandler - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique handler identifier | -| **eventName** | `string` | ✅ | Name of event to handle (supports wildcards like user.*) | -| **handler** | `any` | ✅ | Handler function | -| **priority** | `integer` | ✅ | Execution priority (lower numbers execute first) | -| **async** | `boolean` | ✅ | Execute in background (true) or block (false) | -| **retry** | `{ maxRetries: integer; backoffMs: integer; backoffMultiplier: number }` | optional | Retry policy for failed handlers | -| **timeoutMs** | `integer` | optional | Handler timeout in milliseconds | -| **filter** | `any` | optional | Optional filter to determine if handler should execute | - - ---- - -## EventLogEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique log entry identifier | -| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | The event | -| **status** | `Enum<'pending' \| 'processing' \| 'completed' \| 'failed'>` | ✅ | Processing status | -| **handlersExecuted** | `{ handlerId: string; status: Enum<'success' \| 'failed' \| 'timeout'>; durationMs?: integer; error?: string }[]` | optional | Handlers that processed this event | -| **receivedAt** | `string` | ✅ | When event was received | -| **processedAt** | `string` | optional | When event was processed | -| **totalDurationMs** | `integer` | optional | Total processing time | - - ---- - -## EventMessageQueueConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>` | ✅ | Message queue provider | -| **topic** | `string` | ✅ | Topic or queue name | -| **eventPattern** | `string` | ✅ | Event name pattern to publish (supports wildcards) | -| **partitionKey** | `string` | optional | JSON path for partition key (e.g., "metadata.tenantId") | -| **format** | `Enum<'json' \| 'avro' \| 'protobuf'>` | ✅ | Message serialization format | -| **includeMetadata** | `boolean` | ✅ | Include event metadata in message | -| **compression** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4'>` | ✅ | Message compression | -| **batchSize** | `integer` | ✅ | Batch size for publishing | -| **flushIntervalMs** | `integer` | ✅ | Flush interval for batching | - - ---- - -## EventMetadata - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **source** | `string` | ✅ | Event source (e.g., plugin name, system component) | -| **timestamp** | `string` | ✅ | ISO 8601 datetime when event was created | -| **userId** | `string` | optional | User who triggered the event | -| **tenantId** | `string` | optional | Tenant identifier for multi-tenant systems | -| **correlationId** | `string` | optional | Correlation ID for event tracing | -| **causationId** | `string` | optional | ID of the event that caused this event | -| **priority** | `Enum<'critical' \| 'high' \| 'normal' \| 'low' \| 'background'>` | ✅ | Event priority | -| **cluster** | `{ scope: Enum<'local' \| 'cluster' \| 'tenant'>; deliverySemantics?: Enum<'best-effort' \| 'at-least-once' \| 'exactly-once'>; partitionKey?: string }` | optional | Per-emit cluster routing & delivery options. See cluster-semantics.mdx §4. | - - ---- - -## EventPersistence - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | -| **filter** | `any` | optional | Optional filter function to select which events to persist | -| **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | ✅ | Storage backend for persisted events | - - ---- - -## EventPriority - -### Allowed Values - -* `critical` -* `high` -* `normal` -* `low` -* `background` - - ---- - -## EventQueueConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Event queue name | -| **concurrency** | `integer` | ✅ | Max concurrent event handlers | -| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Default retry policy for events | -| **deadLetterQueue** | `string` | optional | Dead letter queue name for failed events | -| **priorityEnabled** | `boolean` | ✅ | Process events based on priority | - - ---- - -## EventReplayConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fromTimestamp** | `string` | ✅ | Start timestamp for replay (ISO 8601) | -| **toTimestamp** | `string` | optional | End timestamp for replay (ISO 8601) | -| **eventTypes** | `string[]` | optional | Event types to replay (empty = all) | -| **filters** | `Record` | optional | Additional filters for event selection | -| **speed** | `number` | ✅ | Replay speed multiplier (1 = real-time) | -| **targetHandlers** | `string[]` | optional | Handler IDs to execute (empty = all) | - - ---- - -## EventRoute - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **from** | `string` | ✅ | Source event pattern (supports wildcards, e.g., user.* or *.created) | -| **to** | `string[]` | ✅ | Target event names to route to | -| **transform** | `any` | optional | Optional function to transform payload | - - ---- - -## EventSourcingConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable event sourcing | -| **snapshotInterval** | `integer` | ✅ | Create snapshot every N events | -| **snapshotRetention** | `integer` | ✅ | Number of snapshots to retain | -| **retention** | `integer` | ✅ | Days to retain events | -| **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | -| **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | - - ---- - -## EventTypeDefinition - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Event type name (lowercase with dots) | -| **version** | `string` | ✅ | Event schema version | -| **schema** | `any` | optional | JSON Schema for event payload validation | -| **description** | `string` | optional | Event type description | -| **deprecated** | `boolean` | ✅ | Whether this event type is deprecated | -| **tags** | `string[]` | optional | Event type tags | - - ---- - -## EventWebhookConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique webhook identifier | -| **eventPattern** | `string` | ✅ | Event name pattern (supports wildcards) | -| **url** | `string` | ✅ | Webhook endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>` | ✅ | HTTP method | -| **headers** | `Record` | optional | HTTP headers | -| **authentication** | `{ type: Enum<'none' \| 'bearer' \| 'basic' \| 'api-key'>; credentials?: Record }` | optional | Authentication configuration | -| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Retry policy | -| **timeoutMs** | `integer` | ✅ | Request timeout in milliseconds | -| **transform** | `any` | optional | Transform event before sending | -| **enabled** | `boolean` | ✅ | Whether webhook is enabled | - - ---- - -## RealTimeNotificationConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable real-time notifications | -| **protocol** | `Enum<'websocket' \| 'sse' \| 'long-polling'>` | ✅ | Real-time protocol | -| **eventPattern** | `string` | ✅ | Event pattern to broadcast | -| **userFilter** | `boolean` | ✅ | Filter events by user | -| **tenantFilter** | `boolean` | ✅ | Filter events by tenant | -| **channels** | `{ name: string; eventPattern: string; filter?: any }[]` | optional | Named channels for event broadcasting | -| **rateLimit** | `{ maxEventsPerSecond: integer; windowMs: integer }` | optional | Rate limiting configuration | - - ---- - diff --git a/content/docs/references/kernel/state-machine.mdx b/content/docs/references/kernel/state-machine.mdx deleted file mode 100644 index a0d9a5e15c..0000000000 --- a/content/docs/references/kernel/state-machine.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: State Machine -description: State Machine protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/kernel/state-machine.zod.ts` - - -## TypeScript Usage - -```typescript -import { Event } from '@objectstack/spec/kernel'; -import type { Event } from '@objectstack/spec/kernel'; - -// Validate data -const result = Event.parse(data); -``` - ---- - -## Event - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique event identifier | -| **name** | `string` | ✅ | Event name (lowercase with dots, e.g., user.created, order.paid) | -| **payload** | `any` | ✅ | Event payload schema | -| **metadata** | `{ source: string; timestamp: string; userId?: string; tenantId?: string; … }` | ✅ | Event metadata | - - ---- - diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index af3e3fd36d..e2fdb6bad1 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -5,10 +5,6 @@ description: Misc protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/security/misc.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/shared/metadata-persistence.mdx b/content/docs/references/shared/metadata-persistence.mdx index 184a882589..30669611cf 100644 --- a/content/docs/references/shared/metadata-persistence.mdx +++ b/content/docs/references/shared/metadata-persistence.mdx @@ -5,10 +5,6 @@ description: Metadata Persistence protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/shared/metadata-persistence.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/studio/action.mdx b/content/docs/references/studio/action.mdx index 492761a89f..7f76880e66 100644 --- a/content/docs/references/studio/action.mdx +++ b/content/docs/references/studio/action.mdx @@ -5,10 +5,6 @@ description: Action protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/studio/action.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 6c46a6c42b..ead4e38dc1 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -66,6 +66,13 @@ const result = Book.parse(data); | **order** | `number` | optional | Orders books within the portal | | **audience** | `'org' \| 'public' \| { permissionSet: string }` | optional | Access audience; defaults to 'org' (inherits package grant) | | **groups** | `{ key: string; label: string; translations?: Record; order?: number; … }[]` | ✅ | The spine: ordered sections. Two levels total. | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/system/core-services.mdx b/content/docs/references/system/core-services.mdx index 5a28e0fba2..fd17fb5884 100644 --- a/content/docs/references/system/core-services.mdx +++ b/content/docs/references/system/core-services.mdx @@ -52,7 +52,6 @@ const result = CoreServiceName.parse(data); * `ai` * `i18n` * `ui` -* `workflow` --- @@ -67,7 +66,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | | **options** | `Record` | optional | | @@ -90,7 +89,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | | **enabled** | `boolean` | ✅ | | | **status** | `Enum<'running' \| 'stopped' \| 'degraded' \| 'initializing'>` | ✅ | | | **version** | `string` | optional | | diff --git a/content/docs/references/system/doc.mdx b/content/docs/references/system/doc.mdx index d7800d34dc..64097347a2 100644 --- a/content/docs/references/system/doc.mdx +++ b/content/docs/references/system/doc.mdx @@ -68,6 +68,13 @@ const result = Doc.parse(data); | **order** | `number` | optional | Sort key within a book group (ADR-0046 §6) | | **group** | `string` | optional | Explicit book-group key (ADR-0046 §6); rules usually suffice | | **translations** | `Record` | optional | Per-locale `{label?,description?,content}` variants; the base doc is the fallback | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index d7798616e5..e8d400a4b6 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -65,6 +65,13 @@ const result = CronSchedule.parse(data); | **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = backoffMs * backoffMultiplier^(retry-1)) up to maxRetries retries after the initial attempt (#3494). Omit for the legacy single-attempt behavior. | | **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | | **enabled** | `boolean` | optional | Whether the job is enabled | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/system/metadata-loader.mdx b/content/docs/references/system/metadata-loader.mdx index 43bc2a5c7d..e6c4f90e37 100644 --- a/content/docs/references/system/metadata-loader.mdx +++ b/content/docs/references/system/metadata-loader.mdx @@ -5,10 +5,6 @@ description: Metadata Loader protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/system/metadata-loader.zod.ts` - - ## TypeScript Usage ```typescript @@ -42,7 +38,7 @@ const result = MetadataFallbackStrategy.parse(data); | **tableName** | `string` | ✅ | Database table name for metadata storage | | **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | ✅ | Fallback strategy when datasource is unavailable | | **rootDir** | `string` | optional | Root directory path | -| **formats** | `Enum<'json' \| 'yaml' \| 'typescript' \| 'javascript'>[]` | ✅ | Enabled formats | +| **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | ✅ | Enabled formats | | **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | | **watch** | `boolean` | ✅ | Enable file watching | | **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options | diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx new file mode 100644 index 0000000000..ac02268e99 --- /dev/null +++ b/content/docs/references/ui/bulk-action.mdx @@ -0,0 +1,106 @@ +--- +title: Bulk Action +description: Bulk Action protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Bulk Action Schemas + +The vocabulary of a list view's `bulkActionDefs` — one entry per button in + +the multi-select toolbar. Use a def for a mass data-plane mutation that no + +action expresses (`operation: 'update'` with a patch, or `'delete'`), or for + +an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the + +action it NAMES once for the whole selection. + +For the per-record dispatch, name the action in the view's + +`bulkActions: ['']` instead — the bare-string form, promoted with the + +action's own label, params and `visible`. + + +**Source:** `packages/spec/src/ui/bulk-action.zod.ts` + + +## TypeScript Usage + +```typescript +import { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui'; +import type { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui'; + +// Validate data +const result = BulkActionDef.parse(data); +``` + +--- + +## BulkActionDef + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. | +| **label** | `string` | optional | Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in `bulkActions` to get localization). | +| **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). | +| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. | +| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | +| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | +| **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | +| **params** | `Record[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | +| **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. | +| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. | + + +--- + +## BulkActionExecution + +### Allowed Values + +* `perRecord` +* `aggregate` + + +--- + +## BulkActionOperation + +### Allowed Values + +* `update` +* `delete` +* `custom` + + +--- + +## BulkActionParam + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Param key — becomes params[name] in the patch / action params bag. | +| **label** | `string` | optional | Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header). | +| **help** | `string` | optional | Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.) | +| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | ✅ | Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…). | +| **required** | `boolean` | optional | Blocks the Confirm button until a value is present. | +| **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) | +| **options** | `{ label: string; value: string \| number \| boolean }[]` | optional | Static options for select-style widgets. | +| **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) | +| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). | +| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. | +| **placeholder** | `string` | optional | Placeholder text. | + + +--- + diff --git a/content/docs/references/ui/http.mdx b/content/docs/references/ui/http.mdx index 5d5b81ce53..dab575e0eb 100644 --- a/content/docs/references/ui/http.mdx +++ b/content/docs/references/ui/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/ui/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx index f48b8ad1c6..bb696a8110 100644 --- a/content/docs/references/ui/index.mdx +++ b/content/docs/references/ui/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack. + diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json index 9fbd8f7c70..6b286829f4 100644 --- a/content/docs/references/ui/meta.json +++ b/content/docs/references/ui/meta.json @@ -25,6 +25,8 @@ "http", "i18n", "notification", - "sharing" + "sharing", + "---More---", + "bulk-action" ] } \ No newline at end of file diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index fb3ad05547..72e8b22023 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -399,7 +399,7 @@ List chart view configuration | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets | | **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | @@ -487,7 +487,7 @@ List chart view configuration | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets | | **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 11af8ae3d4..7eb98c58cf 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -291,7 +291,7 @@ Every route carries the `/api/v1` prefix. When project scoping is enabled each r - Client SDK supports bearer token header — but token validation requires the auth plugin - Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered - Fine-grained authorization (RLS, sharing) lives in `plugin-security` / `plugin-sharing`, not in the auth plugin. Territory-style access is expressed as an RLS dynamic-membership set (`ExecutionContext.rlsMembership`, e.g. `id in current_user.territory_account_ids`) rather than as a dedicated territory module -- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 18 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key. +- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 17 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key. - **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). **A sharing rule must state its criteria** (#3896): all three write paths reject a match-all shape, a stored criteria-less rule matches nothing, and its materialised grants are revoked on the next reconcile. --- @@ -439,7 +439,7 @@ There is no MSW package in this repo — browser API mocking is a devDependency - [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). Canonical vocabulary only — legacy aliases removed from the enum (ADR-0090 D4); unset custom-object OWD resolves to `private` (ADR-0090 D1) - [x] Sharing Rule evaluator — criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); every authorable recipient maps 1:1 onto an enforced `expandRecipient` branch (`plugin-sharing/sharing-rule-service.ts`) — `user`, `team`, `position`, `business_unit`, and `unit_and_subordinates` (business-unit-subtree widening, ADR-0057 D5 / ADR-0090 D3). Under ADR-0078 enforce-or-remove, `criteria` is now the only rule *type* (owner-type rules were removed from the authoring surface because the static materialiser cannot track live membership), the `group` recipient was renamed to `team`, `guest` was removed, and `queue` stays reserved in the runtime contract but deliberately non-authorable - [x] Everyone-baseline suggestion — a permission set may set `isDefault: true` as the install-time suggestion to bind it to the built-in `everyone` position; resolved per-request as an additive baseline, no fallback cliff (ADR-0090 D5) -- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 18 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A) +- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 17 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A) - [ ] Studio RLS visual editor - [ ] Per-user×org permission cache - [ ] Audit UI / denied-access logging diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 3c3ac3da59..1734ecf7ee 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -412,20 +412,28 @@ schema to the two highest-risk authorable surfaces, per the triage in - **Datasources** — `DatasourceSchema` with its `pool` / `healthCheck` / `ssl` / `retryPolicy` blocks, the ADR-0015 `external` federation settings and their `validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`. - `config` and `readReplicas` stay **open** records: their shape is per-driver. - Nothing validates *inside* them — an earlier version of this note said the - driver's own `configSchema` did, which was wrong; the per-driver schemas exist - (`PostgresConfigSchema` and siblings) but nothing parses `config` against - them, tracked as #4410. So a misspelling one level *down* is still silent - today. That openness is why the + `config` stays an **open** record: its shape is per-driver. What it no longer + is, is unvalidated — #4410 wired the per-driver schemas + (`PostgresConfigSchema` and siblings) into `DatasourceSchema`'s refinement, so + a misspelling one level *down* is now rejected with the canonical key named. + An earlier version of this note said the driver's own `configSchema` did that, + which was wrong for two releases: the field existed, nothing read it. That + openness is why the top level had to close — a connection key written one level too high (`host` next to `driver` instead of inside `config`) was stripped, and the datasource then connected on driver defaults rather than failing. Those keys now prescribe the move into `config`; a top-level `password` is instead pointed at `external.credentialsRef`, because relocating an inlined secret is not the fix. - A dropped key in `capabilities` was quieter still: an unregistered capability - reads as `false`, so the engine stopped pushing that work down to the driver - and recomputed it in memory. + A dropped key in `capabilities` was quieter still — though not for the reason + this note used to give. It claimed an unregistered capability "reads as + `false`, so the engine stopped pushing that work down to the driver and + recomputed it in memory", which was never true: the #4487 liveness audit + found the whole `capabilities` block has no reader at all. The engine gates + pushdown on the runtime driver's own `supports.*` object, a different + mechanism with a non-overlapping vocabulary. Every key in the block is `dead` + in `liveness/datasource.json`, and `capabilities.readOnly` is the one to know + about: it reads as a safety switch and gates nothing — `external.allowWrites: + false` is the enforced write gate. One clarification, since these flips are easy to over-read: making a schema strict does **not** change its published JSON Schema. `build-schemas.ts` @@ -930,6 +938,50 @@ honoured — `updateManyData`, `deleteManyData` and `batchData` persisted regardless, so a caller sending it to *preview* a mutation got it executed. It is HTTP-only; stop sending it. +### `findOne` must say which record it wants (#4419) + +`findOne` reads a single row. That makes its predicate the only thing standing +between the caller and *an arbitrary record* — and when the predicate is missing +the result is not `null`, it is the object's **first row**: a real, +plausible-looking record with nothing to do with the request, which the +`if (!row)` check every call site already has cannot catch, and which then +propagates into whatever is computed next. Downstream of one such call, line +items defaulted their price from the first product in the catalog, and +"is this deal already closed?" was answered against an unrelated record while +the write that followed correctly targeted the intended id. + +So a query that selects nothing in particular is now **refused** rather than +answered. Say which record you want in one of three ways: + +| Instead of | Write | Meaning | +|---|---|---| +| `findOne(o)` / `findOne(o, {})` / `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | +| | `findOne(o, { search: 'Acme' })` | the record this search finds | +| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | +| | `find(o, { limit: 1 })` | any row will genuinely do — and the call site says so | + +The error names all four. `find` and `count` are unchanged: returning or counting +every row is an honest answer, and only `findOne`'s implicit "just one of them" +turns a missing predicate into a confidently wrong record. + +Two silent drops that produced the same wrong record are fixed with it: + +- **`findOne({ search })` now applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion ran in `find` only, while both methods are + checked against the same legal-key set — so `search` passed the gate, reached a + driver that does not read it, and the read came back unpredicated. The + expansion is now one function both call, and a drift pin requires every option + `findOne` declares to have an observable effect. +- **`MongoDBDriver.findOne` now applies `orderBy`, `fields` and `offset`.** It + translated `where` and dropped the rest, so "the newest record" returned + whichever document the scan reached first. No ordering is imposed when the + caller supplies none (#4363) — that part is unchanged, on both drivers. + +Together with the `filter` → `where` fold on every entry point and the +unknown-key rejection (both already in this release), a read parameter the engine +does not execute now fails at the call site instead of quietly changing the +answer. + ### Dead spec clusters removed **App shell (2026-06 liveness audit, #4001 app step).** `App.version`, @@ -970,6 +1022,11 @@ import or the authored key. | `DEFAULT_DISPATCHER_ROUTES` | dead route table | | Aspirational config on Theme / Translation / Webhook | still-dead after #3494 | | `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) | +| The `workflow` service slot — `CoreServiceName 'workflow'`, `IWorkflowService`, `WorkflowProtocol`, the `Get/WorkflowState/Config/Transition` schema cluster, discovery `routes.workflow` / `services.workflow` / `features.workflow`, the `RestApiRouteCategory 'workflow'` member and the stray `graphql` provider entry | declared end to end and implemented nowhere: nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method of `WorkflowProtocol` was ever implemented, no host ever mounted `/api/v1/workflow`. State machines are `state_machine` validation rules; approvals are flow nodes (ADR-0019); record-triggered automation is hooks + `record_change` flows (#4451) | +| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) | +| The per-provider connector "template" cluster (`@objectstack/spec/integration` — `DatabaseConnectorSchema`, `FileStorageConnectorSchema`, `GitHubConnectorSchema`, `MessageQueueConnectorSchema`, `SaasConnectorSchema`, `VercelConnectorSchema`, their ~100 sub-schema/type/example exports, and the six generated reference pages) | the losing side of a decided architecture fight, left standing: ADR-0023 rejected hand-modelling each external system's shape inside the spec, and the live ADR-0097 protocol gets provider shapes from the provider itself (connector-openapi / connector-mcp materialize at boot). Zero consumers — `engine.registerConnector()` validates against `ConnectorSchema` from `connector.zod.ts` alone, and nothing referenced the six files, not even their own module's live half. `DatabaseConnectorSchema` also declared read-replica routing a *second* time (`readReplicaConfig`, see the row above), down to a `weight` field for a load balancer that does not exist (#4480) | +| The `trigger-registry.zod.ts` Connector cluster (`@objectstack/spec/automation` — `ConnectorSchema`, `ConnectorInstanceSchema`, `ConnectorOperationSchema`, `ConnectorTriggerSchema`, the `Authentication*`/`OAuth2Config`/`Operation*` vocabulary, the `Connector.apiKey()`/`.oauth2()` factory helpers, and the generated reference page) | the *third* declaration of the same business need, and the file never contained what its name promises — no trigger registry, 630 lines of connector vocabulary with zero consumers. The automation engine registers connectors against `integration/connector.zod.ts` (ADR-0097) and the stack `connectors:` collection parses `DeclarativeConnectorEntrySchema`; nothing registered, validated or executed against this copy. Its header even carried a "When to use" comparison steering lightweight cases here — a signpost to a dead end, removed with it (#4499) | +| The `kernel` metadata-loader envelope family — `MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo`, `MetadataLoaderContract` (`@objectstack/spec/kernel`) | eleven names that each existed **twice**, with a different shape, on `./kernel` and `./system` — so which type you got depended on your import path. Every consumer imported the `./system` copy; the `./kernel` copies had zero consumers. Import them from `@objectstack/spec/system` (#4411, ADR-0049). `MetadataManagerConfig` / `MetadataFallbackStrategy` are unaffected and still ship from both entries | The Console side follows: `@object-ui/types` drops its `ObjectStack`/`ObjectOS`/`ObjectQL`/`ObjectUI` Capabilities re-exports, which @@ -1672,7 +1729,12 @@ platform capabilities an administrator gains, then the Console delta. source at every read seam (including `registerFlow` rehydration) — a stored action with the removed `execute` dispatches via `target` again; boot hydration validates each row post-conversion and diagnoses invalid ones - with `[metadata_spec_invalid]` instead of shrugging. + with `[metadata_spec_invalid]` instead of shrugging. **The rows themselves + can now be brought forward too (#4327):** `os migrate meta --stored` + replays the chain over `sys_metadata` and rewrites what still carries a + pre-protocol shape, through the normal write path. Optional — the read path + is the guarantee either way — but it is what makes the conversion pass a + no-op on your data instead of a permanent shim. - **Studio's metadata forms tell the truth (#3786).** Four of seventeen forms had drifted from their schemas, so controls saved nothing: the Object → Capabilities toggles bound a key the schema does not declare (all seven @@ -1977,9 +2039,21 @@ covers are folded into the list below rather than left to the changelog.) fix what it reports before `--apply`. It converts nothing — the values it names are application data — and a scan that was truncated or could not read an object fails the gate even at zero violations. +- **Stored metadata (optional):** run `os migrate meta --stored` to see which + `sys_metadata` rows still carry a pre-17 shape, and `--apply` to rewrite + them. Unlike the two above this opens no gate and nothing depends on it — + those rows already read canonically, forever. It stops them re-converting on + every load, keeps diffs and exports clean going forward, and gives you an + exit code to assert on: nothing left to do exits `0`. - **Datasources:** verify every declared datasource connects in every environment — a bound datasource that cannot connect now fails the boot - instead of failing every later query. + instead of failing every later query. Delete `readReplicas` (`os migrate meta` + does it). Nothing ever opened those connections, so read throughput is + unchanged by removing them; if you need replica reads, front them behind one + endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there. + Also re-check what you wrote under `config`: it is parsed against the driver's + contract now, so a key that used to be ignored — and left the datasource on + driver defaults — is rejected by name. - **Sharing rules:** rewrite `sharedWith.type: 'group'` → `'team'`; drop `guest` and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`. **A rule must state its criteria** — authoring one without is rejected, and a @@ -2025,6 +2099,13 @@ covers are folded into the list below rather than left to the changelog.) device/preference helpers, `client.ai.{nlq,suggest,insights}` and `projects.listTemplates()`; repoint marketplace publish to `POST /api/v1/packages/publish`; drop `os environments create --template`. + Imports of `IWorkflowService`, `WorkflowProtocol` or the + `Get/WorkflowState/Config/Transition` types no longer resolve — the + `workflow` slot retired with them (#4451); use `state_machine` validation + rules, approval flow nodes and `record_change` flows instead. Discovery + responses no longer carry `services.workflow` / `routes.workflow` / + `features.workflow` — a reader keying on them saw only `unavailable`/`false` + before, so delete the read. - **Console hosts:** the same removals land in objectui — drop the `useClientNotifications` device/preference delegates (`@object-ui/react`) and replace the retired `@object-ui/types` Capabilities re-exports with imports @@ -2032,7 +2113,16 @@ covers are folded into the list below rather than left to the changelog.) `maplibre-gl` 5→6 / `chalk` 5→6 major bumps. - **Type importers:** replace `ObjectStackProtocol` / `ObjectStackProtocolSchema` with the narrowest per-domain slices; drop GraphQL types and any of the removed - dead spec clusters. + dead spec clusters. If you imported `MetadataFormat`, `MetadataStats`, + `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataExportOptions`, + `MetadataImportOptions`, `MetadataLoadResult`, `MetadataSaveResult`, + `MetadataWatchEvent`, `MetadataCollectionInfo` or `MetadataLoaderContract` + from `@objectstack/spec/kernel`, change the path to `@objectstack/spec/system` + — same names, and that copy is the one the runtime has always emitted. It is + the *looser* of the two, so a reader may need new narrowing: notably + `MetadataWatchEvent.type` also carries the raw watcher values + `add`/`change`/`unlink`, and `metadataType`/`name`/`timestamp` are optional + there. Nothing to migrate at runtime — the values were always these. - **Multi-org:** the `group` posture requires the enterprise runtime — deployments relying on it self-activating must install `@objectstack/organizations` or move to `isolated`. diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index d81ca55ac8..9060705ef2 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -210,9 +210,17 @@ defineView({ ``` -Naming an action in a widget does **not** bypass location filtering — the +Naming an action in a **widget** does not bypass location filtering — the engine still requires the action to declare the matching location (that's why `MarkDoneAction` above includes `record_section`). + +The **selection bar is the exception**, and the only one: an action named in a +list view's `bulkActions` or `bulkActionDefs` is placed by that declaration, +not by `locations`. That is what the retired `action.bulkEnabled` tombstone +prescribes ("the multi-select toolbar is driven by the LIST VIEW's +`bulkActions` / `bulkActionDefs`"), and it is what lets an aggregate bulk +action — one that acts on a whole selection and has no single-record home by +construction — exist at all. ## Collect input and shape the UX diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 875a3183fe..55e4e13323 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -144,7 +144,7 @@ Components are the building blocks placed inside regions. The `type` field is a union of the standard `PageComponentType` enum and any custom string. The standard (namespaced) component types include: - **Structure:** `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section` -- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` +- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` — each renders from the record context a **record page** mounts, so they belong on a `type:'record'` page. A `kind:'react'` page mounts no such context and `os validate` rejects them there (see Validating metadata §10b) - **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb` - **Utility:** `global:search`, `global:notifications`, `user:profile` - **AI:** `ai:chat_window`, `ai:suggestion` diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index ee837f9647..8d4f6826cd 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -110,6 +110,7 @@ A List View controls how a collection of records is presented. It supports multi | `navigation` | `object` | optional | Row click navigation | | `rowActions` | `array` | optional | Per-row action buttons, by action name — see [Actions](/docs/ui/actions) | | `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) | +| `bulkActionDefs` | `array` | optional | Rich bulk action definitions — mass edits, and the aggregate single-call mode (below) | | `inlineEdit` | `boolean` | optional | Enable inline editing | | `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) | @@ -162,6 +163,58 @@ columns: [ | `align` | `'left' \| 'center' \| 'right'` | Text alignment | | `link` | `boolean` | Cell functions as the primary navigation link | +### Bulk Actions Over a Selection + +Two keys drive the multi-select toolbar. `bulkActions` names actions the object +already declares, and each selected record is dispatched **once** — the action +runs N times for N rows. `bulkActionDefs` carries richer entries: a mass edit +through the data API (`operation: 'update'` + a patch), or the **aggregate** +mode, where the action is called **once for the whole selection**. + +```typescript +bulkActions: ['mark_done'], // one dispatch per selected record +bulkActionDefs: [ + // Mass edit — one bulk write, no action involved. + { name: 'archive', operation: 'update', patch: { archived: true } }, + // Aggregate — ONE call to the declared `export_zip` action, carrying every + // selected id. This is the "N devices → one zip download" shape; also batch + // print, merged-PDF export. + { name: 'export_zip', operation: 'custom', execution: 'aggregate' }, +] +``` + +An aggregate dispatch delivers the selection to the handler as +`params._selectedIds: string[]` — read that, **not** `recordId`. Results are +all-or-nothing: a handler that cannot cover the whole selection must reject, +and per-row retry is replaced by re-running the action. `batchSize` does not +apply (the call is never chunked); set `maxRecords` when the server work is +expensive. + +**Pick the right key for the dispatch you mean.** Per-record is +`bulkActions: ['']` — the bare-string form, which has nowhere to carry a +flag and is promoted with the action's own label, params and `visible`. +Aggregate is the def form, which is where `execution` lives. A def that says +`operation: 'custom'` without `execution: 'aggregate'` is rejected at parse +time: the renderer has no action attached to such a def, so it used to render a +button that reported success for every selected record and did nothing. + +A url or api action rendered on the list **toolbar** can also read the current +selection through target interpolation — `${ctx.selection.ids}` (comma-joined) +and `${ctx.selection.count}` — without any bulk wiring. + + + `action.bulkEnabled` was retired in spec 17. The multi-select toolbar is + driven by these two view keys only. + + +A `bulkActionDefs` entry is a typed shape — see the +[BulkActionDef reference](/docs/references/ui/bulk-action). Unknown keys are +rejected with the canonical spelling named, and so are keys the executor would +never read (`patch` outside an `update`, `execution` outside a `custom`, +`batchSize` on an aggregate). One key is deliberately not authorable: +`actionDef` is attached by the renderer when it resolves the def's `name`, and +writing it by hand would smuggle an action definition past the action registry. + ### Data Source Views can load data from several sources (`object`, `api`, `value`, and `schema`): diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md index cbf79f8378..62b6902bb4 100644 --- a/docs/adr/0062-external-datasource-runtime.md +++ b/docs/adr/0062-external-datasource-runtime.md @@ -77,6 +77,15 @@ Introduce a single service that, given a datasource definition, builds a driver Auto-connect must not change apps that today declare datasources that are *decorative* or routed via `datasourceMapping` (e.g. `examples/app-crm`'s `crm_primary`/`crm_analytics`). Gate auto-connect so a declared datasource is only connected when it is meaningfully addressed: **(a)** it is `external` (`schemaMode !== 'managed'`), or **(b)** an object/`datasourceMapping` actually routes to it, or **(c)** it sets an explicit `autoConnect: true`. A managed datasource that nothing routes to stays metadata-only (today's behavior). The `default` datasource keeps its current dedicated bootstrap. This is the load-bearing backward-compat decision. > **Phase 1 implementation note (#2163) — gate (b) is "explicit `object.datasource`", not "mapped".** Implementing D2 against `examples/app-crm` surfaced a conflict between "an object/`datasourceMapping` routes to it" and the "byte-for-byte unchanged" mandate. `app-crm`'s `crm_primary` (`:memory:`, `managed`) *is* referenced by a `datasourceMapping` rule (and is the `default:true` fallback) but has **no** `onEnable` driver, so today `engine.getDriver` finds no `crm_primary` driver and its objects fall through to the `default` driver. Auto-connecting it on the strength of the mapping rule would build a fresh, empty `:memory:` driver and silently divert those objects — a behavior change. So the gate **does not** auto-connect on a `datasourceMapping` rule alone: a *managed* datasource that is only mapped (namespace/package/`default`) is treated as decorative and left metadata-only. Gate (b) fires only when an object **explicitly** binds via `object.datasource === ` — a binding that today *throws* when the driver is unregistered, so auto-connecting it is a strict improvement, never a change. External datasources (a) and `autoConnect:true` (c) are unaffected. See `isDatasourceAddressed()` in `@objectstack/service-datasource`. +> +> **Amendment (#4462) — the phase-1 note is REVERSED: gate (d) is "a mapping rule routes objects here", and mapping-only is no longer decorative.** The note above priced the trade-off with only one side on the table. The other side, measured on `main` during the v17 verification, is what a mapping to an **unreachable** datasource does today: the boot succeeds, `/ready` answers `200`, the datasource name appears in **zero** log lines, `POST /api/v1/data/` returns `201` — and the row is physically in the DEFAULT store. The operator discovers it by opening the database they declared and finding it empty. Weighed against that, "decorative" is not a backward-compatibility guarantee; it is a silent data-placement bug wearing one. `datasourceMapping` reads as routing to every author who writes it, and Route-ownership rule #3 ("absence must be loud; prefer failing to falling back") applies to a routing decision as much as to a mounted surface. +> +> The amendment is a **pair**, and each half is what makes the other correct: +> +> 1. **Routing stops falling through.** `ObjectQLEngine.getDriver` step 2: a mapping rule that MATCHES and names a datasource with no live driver now throws — `DatasourceUnavailableError` when the connect layer recorded a verdict (framework#3828), otherwise a "mapped for object … is not registered" error naming the two remedies. `default` is the one name that still resolves onward: the default driver keeps its natural name (#3826), so `drivers.has('default')` is false by construction and step 5 is how routing to it works. +> 2. **The D2 gate grows (d).** A datasource a mapping rule routes at least one registered object to is auto-connected at boot, and a `declared-auto` failure is **fatal** — the same argument (b) already makes, now true of (d) because half 1 removed the fallback. The object list is resolved by the boot path from the engine's own matcher (`ObjectQLEngine.resolveMappedDatasource`), never re-derived in the connection service: two matchers drifting by one clause would connect a datasource routing never uses, or route to one nothing connects, which is the defect again. +> +> `examples/app-crm`'s mapping was **deleted** in the same change, and that is what keeps the example byte-for-byte unchanged rather than what breaks it: its `namespace: 'crm'` rule never matched (`namespace` is deprecated and no object sets it), and its `default: true → crm_primary` rule routed everything to an unconnected `:memory:` datasource, i.e. to the `default` store by fall-through. Honouring that rule would move the entire app — platform objects included — onto a database that is empty on every boot. Removing the rule states what the example actually does. The general lesson is the one #2163 half-saw: a rule the runtime ignores is not compatibility, it is an unpaid bill. ### D3 — Credentials resolved at connect via `SecretBinder`/`ICryptoProvider` diff --git a/docs/adr/0082-react-component-contract-governance.md b/docs/adr/0082-react-component-contract-governance.md index 99e7343e3b..848fc3bc28 100644 --- a/docs/adr/0082-react-component-contract-governance.md +++ b/docs/adr/0082-react-component-contract-governance.md @@ -3,7 +3,7 @@ **Status**: Accepted (2026-06-30) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0080](./0080-ai-authored-ui-jsx-source.md) (AI authors UI; the component registry `inputs` are the contract; **capability ≠ contract** — curate a small public surface, not the full capability set), [ADR-0081](./0081-trusted-react-page-tier.md) (the `kind:'react'` tier executes real React; its safety boundary is **trust + review**, and its prop ceiling is the **injected scope**), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI writes metadata via draft-gated review), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (ratchet a snapshot; flag regressions, not the accepted baseline), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a prop the author writes must be honored or rejected, never silently dropped). -**Consumers**: `@objectstack/spec` (`packages/spec/src/ui/react-blocks.ts` — the block→schema index + React overlay; `scripts/build-react-blocks-contract.ts` — the generator; `scripts/check-react-blocks-conformance.ts` + `react-conformance.baseline.json` — the ratchet), `@objectstack/lint` (`validate-react-page-props.ts` — the authoring prop gate), `@objectstack/cli` (`os validate` wires the gate), `scripts/build-console.sh` (runs the ratchet at console-build time), `../objectui` (the component registry `inputs` are the projected surface the ratchet checks against). +**Consumers**: `@objectstack/spec` (`packages/spec/src/ui/react-blocks.ts` — the block→schema index + React overlay; `scripts/build-react-blocks-contract.ts` — the generator; `scripts/check-react-blocks-declaration-parity.ts` + `react-declaration-parity.baseline.json` — the ratchet), `@objectstack/lint` (`validate-react-page-props.ts` — the authoring prop gate), `@objectstack/cli` (`os validate` wires the gate), `scripts/build-console.sh` (runs the ratchet at console-build time), `../objectui` (the component registry `inputs` are the projected surface the ratchet checks against). **Premise**: ADR-0081 gave authors (and AI) a `kind:'react'` page tier whose blocks are the curated public data components (``, ``, charts, record:* panels). For AI to author those blocks *correctly* it must know each block's props — and for that knowledge to be trustworthy, the props must come from an authoritative, machine-readable, **non-drifting** source. The problem: **there is no single such source.** Three prop surfaces exist for the same components, and nothing keeps them in lockstep: @@ -24,9 +24,9 @@ They drift silently: a component can accept a prop the spec never declared (an u 1. **[source of truth] The spec zod schema is the protocol.** The AI-facing component contract is **generated** from the spec schemas (`z.toJSONSchema`) plus a thin React-interaction overlay — never hand-authored. Generated ⇒ it cannot drift into fiction. 2. **[registry is a subset] Registry `inputs` are the designer palette, not the protocol.** A prop the spec declares but the registry doesn't expose is a *soft* signal (panel gap), not a violation. A prop the component exposes that the spec doesn't declare is the *actionable* signal (undocumented extension). 3. **[overlay] React-interaction props live in a thin overlay, not the spec.** Callbacks (`onSuccess`, `onRowClick`), controlled props (`recordId`, `mode`, `filters`), and binding escape-hatches (`objectName`, a chart's static `data`, a list's `fields`/`options`) are real React surface the *view metadata* schema neither models nor should. They are declared in `react-blocks.ts`'s overlay so the contract documents them. -4. **[conformance = ratchet, not per-PR gate] Frontend↔spec conformance is checked where the manifest is free.** The registry-inputs manifest only exists at console-build time (the registry is a browser app). So conformance runs **inside `build-console.sh`**, warn-only, as a **baseline ratchet** (ADR-0054 shape): it flags only NEW frontend-only props or vanished blocks against a committed baseline — not the accepted divergence, and not every PR. +4. **[declaration parity = ratchet, not per-PR gate] Spec↔registry parity is checked where the manifest is free.** The registry-inputs manifest only exists at console-build time (the registry is a browser app). So it runs **inside `build-console.sh`** as a **baseline ratchet** (ADR-0054 shape): it flags only NEW registry-only inputs or vanished blocks against a committed baseline — not the accepted divergence, and not every PR. *(Amended by #4472: this said "conformance", ran warn-only, and was read as confirming the components implement the spec props. It compares two declarations and now runs `--strict`. See the addendum.)* 5. **[authoring = a hard gate] `os validate` enforces correct prop *usage*.** A separate `validate-react-page-props` gate parses each `kind:'react'` page's real JSX and checks block usage against the contract: a missing **required binding** is an error; a near-miss **prop typo** is a warning; arbitrary unknown props are *not* flagged (the contract's data props are a curated subset, so false positives stay near zero). -6. **[the chain] Five links, each with one job.** protocol source (spec) → generated contract (`react-blocks.md`) → conformance ratchet (build-console.sh) → authoring prop gate (os validate) → a dogfood golden page proving the loop closes. +6. **[the chain] Five links, each with one job.** protocol source (spec) → generated contract (`react-blocks.md`) → declaration-parity ratchet (build-console.sh) → authoring prop gate (os validate) → a dogfood golden page proving the loop closes. **No link in this chain observes a render** — see the addendum for what that costs and where the missing evidence now comes from. --- @@ -55,17 +55,19 @@ The spec UI schemas are *view metadata* — declarative, serializable configurat These are declared in the `react-blocks.ts` overlay with a `kind` of `binding`/`controlled`/`callback`. Declaring a genuine binding in the overlay is how a "frontend-only" prop is *closed* — the divergence was "the component accepts a prop the contract doesn't document," and the fix is to document it, not to leave it as accepted noise (framework #2488 took every block to **0 frontend-only** this way). -### 4. Conformance is a build-time baseline ratchet, not a per-PR gate +### 4. Declaration parity is a build-time baseline ratchet, not a per-PR gate -`scripts/check-react-blocks-conformance.ts` compares the spec props (per block, via `z.toJSONSchema`) against the registry-inputs manifest (`sdui.manifest.json`). The manifest **only exists at console-build time** — the registry is a browser app pulling browser-only deps, so a framework PR has no manifest to check against. Running conformance on every PR is therefore not worth it. +> **Corrected by #4472 — see the addendum.** This decision was written and implemented as "conformance": the check was named `check:react-conformance`, and its script header claimed it confirmed the components "ACTUALLY implement" the spec props. It does not and never did — it compares **two declarations**, and it was **warn-only** besides. The mechanism below is real and kept; the words for it are now `check:react-declaration-parity`, and the gate now runs `--strict`. -Instead, conformance runs **inside `build-console.sh`**, immediately after it dumps the manifest (near-zero marginal cost), as a **baseline ratchet** modeled on ADR-0054: +`scripts/check-react-blocks-declaration-parity.ts` compares the spec props (per block, via `z.toJSONSchema`) against the registry-inputs manifest (`sdui.manifest.json`). The manifest **only exists at console-build time** — the registry is a browser app pulling browser-only deps, so a framework PR has no manifest to check against. Running it on every PR is therefore not worth it. -- `react-conformance.baseline.json` stores each block's accepted frontend-only prop *set* + whether it is missing. -- `--baseline` reports **only regressions**: a block exposing a NEW frontend-only prop, or a previously-present block that vanished. The soft spec-only signal is not gated. -- It is **warn-only** in the console build (never fails it). `--strict` exits non-zero on regression for intentional gating; `--update` re-accepts the current state after a deliberate frontend change. +Instead, it runs **inside `build-console.sh`**, immediately after it dumps the manifest (near-zero marginal cost), as a **baseline ratchet** modeled on ADR-0054: -Because the baseline was driven to **0 frontend-only** (decision 3), the ratchet is noise-free: any future frontend-only prop is a real, actionable signal rather than one sitting in an accepted baseline. +- `react-declaration-parity.baseline.json` stores each block's accepted registry-only input *set* + whether it is missing. +- `--baseline` reports **only regressions**: a block declaring a NEW registry-only input, or a previously-present block that vanished. The soft spec-only signal is not gated. +- It runs `--strict` in the console build, so a regression **fails** it. `--update` re-accepts the current state after a deliberate registry change. + +Because the baseline was driven to **0 registry-only** (decision 3), the ratchet is noise-free: any future registry-only input is a real, actionable signal rather than one sitting in an accepted baseline. ### 5. Authoring correctness is a hard gate at `os validate` @@ -83,8 +85,8 @@ This is the ADR-0078 boundary applied to react pages: a prop the author writes i spec zod schema ──gen──► react-blocks.md (AI reads it — decisions 1–3) (protocol) (generated contract) │ - registry inputs ──────► conformance ratchet (build-console.sh — decision 4) - (designer subset) (warn-only baseline) + registry inputs ──────► declaration-parity ratchet (build-console.sh — decision 4) + (designer subset) (strict baseline; two declarations, no renderer) │ ▼ prop gate (os validate — decision 5) @@ -110,3 +112,30 @@ spec zod schema ──gen──► react-blocks.md (AI reads it — decis - **Hand-author the contract.** Rejected: it drifts into fiction (an earlier Phase-1 hand-authored contract did exactly this). Spec-as-source is zero-drift. - **Treat the registry `inputs` as the source of truth.** Rejected: `inputs` are a curated *subset* (the panel), not the full protocol; sourcing the contract from them would under-document what components actually accept. - **Sandbox/typecheck the React source against generated `.d.ts` for full prop typing.** Out of scope here (and partially covered by ADR-0080's codegen path for the `html` tier); the prop gate's required-binding + typo checks are the pragmatic 80% for `react` authoring without a full type-check harness over executed source. + +--- + +## Addendum (2026-08-01, #4472) — the ratchet compares two declarations; it was named and described as if it compared a declaration to an implementation + +**What was wrong.** Decision 4 shipped as `check:react-conformance`, and the script's header opened by saying it "confirms the objectui components **ACTUALLY implement** the props the spec protocol declares. The spec is the protocol; the frontend must conform." Both halves of its comparison are declarations: + +| left | right | +|---|---| +| the props a block's **spec zod schema** declares (`z.toJSONSchema`) | the inputs the objectui **registry config** declares (`sdui.manifest.json`) | + +The right-hand side comes from objectui's `manifestFromConfigs`, which copies `config.inputs` verbatim. Nothing in the chain looks at a renderer. So a prop **both sides declare and no renderer reads** is, to this gate, perfect agreement — neither declaration is individually false, and the falsehood lives one layer below, in a layer the gate cannot see. + +**What it cost.** #4413: `record:details` / `record:highlights` / `record:related_list` / `record:path` each published `objectName` + `recordId` that no renderer consumed (they take the record from the record page's shared context), so on a `kind:'react'` page all four rendered a "bind a record to preview" placeholder. The committed baseline recorded `{ frontendOnly: [], missing: false }` for all four, and the ratchet stayed green for the defect's entire lifetime. It was found by a human reading the objectui renderers. + +This is Prime Directive #10 (declared ≠ enforced) landing on a gate — the same shape as #1475's "spec declares 9 validation rules, the executor honors 3", except the thing overstating its coverage was the thing whose job is to catch that. **A gate that reports green on a promise it cannot keep is worse than no gate**: without one, someone checks by hand. + +**Corrections.** + +1. **Renamed to what it does** — `check:react-declaration-parity`, `check-react-blocks-declaration-parity.ts`, `react-declaration-parity.baseline.json`, and `frontendOnly` → `registryOnly` in the baseline (the old name implied the frontend *implemented* the prop; it means the registry *declared* it). The name was load-bearing in the misreading, so it had to change with the header. +2. **The scope caveat rides in the output, on every run** — including a clean one. Whoever forms a belief about this gate is reading a CI log, not a source header. +3. **It actually gates.** `gen-sdui-manifest.sh` ran it without `--strict` and swallowed the exit code behind a `⚠`, so even the divergence it *could* see was only ever recorded, never stopped. It now runs `--strict`; the ratchet fires only on divergence new since the accepted baseline, so a failure is always a deliberate registry change needing a spec/overlay edit or an explicit `--update`. +4. **The claim is pinned by a test.** `check-react-blocks-declaration-parity.test.ts` asserts both directions of what the gate *can* see, that the caveat is emitted, and that the implementation claim does not come back — the executable half of Prime Directive #10. + +**What is still true.** Decision 2's signal taxonomy is unchanged and worth keeping: `spec-only` (palette gap, soft), `registry-only` (undocumented extension, ratcheted), `missing` (not registered / not public). Exactly one class is invisible: both sides declare it, nothing reads it. + +**Where the missing evidence now comes from.** Evidence about the render path has to be taken from the render path, which lives in objectui. `apps/console/src/__tests__/public-block-binding-reach.test.tsx` (objectui) mounts every public block that declares an `objectName` input through `SchemaRenderer` with nothing but that binding, under a provider whose `dataSource` records every call, and asserts some call carried the object name. Deliberately narrow — "is this binding wired", not "is every declared input consumed", which is not decidable from outside without heuristics — and every non-reaching block carries a written reason in a ledger asserted to equal the observed set in both directions. Its first run separated five bound blocks from three unbound ones and surfaced two real defects of the #4413 shape (objectui#3144), which is the confirmation that this evidence was never obtainable from here. diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index d11fe813d2..ad1bbf46ab 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -421,3 +421,114 @@ diverged (#3903). This addendum extends the contract to data at rest: at boot would unhook live tables and make the row unfixable in Studio (availability over purity for data at rest; the same verdict reaches Studio as `_diagnostics` on every read). + +## Addendum (2026-08-01) — the stored chain gets a finish line (#4327) + +The addendum above makes a legacy row read canonical *forever*, which is the +correctness guarantee — and, read literally, also a promise that the chain runs +on that row forever. `os migrate meta --stored` +(`ObjectStackProtocolImplementation.migrateStoredMetadata`) lets a deployment +end that for itself: it walks `sys_metadata` (active + draft, all orgs), replays +the same `applyConversionsToStoredItem` pass, and re-saves each changed body +through `saveMetaItem` with `source: 'migrate-stored'` — history row, checksum, +mutation projectors and all. Preview is the default; `--apply` is the only +writing mode. + +- **Not load-bearing, and no flag.** #3855's conclusion stands: an operator-run + migration cannot be relied on, so the read path — not this — remains the + guarantee, and nothing gates on it having run. Deliberately no `sys_migration` + row either: unlike ADR-0104's two gates, a flag here would advertise + enforcement that does not exist. The verifiable statement operators wanted is + the **re-run** — a second pass reporting every row canonical exits 0, so "my + metadata is on protocol N" is a check rather than a belief. +- **The write path's gate is not bypassed.** A body that still fails the current + schema after conversion is refused (422) and reported, exactly as the bullet + above describes for reads: it is a genuine contract violation, and the pass + has no more standing to persist it than an author does. It keeps reading + through the chain and stays fixable in Studio. +- **The version layer stays verbatim.** `sys_metadata_history` is appended to, + never rewritten. Canonicalizing a past version's body would break the + checksum↔body pairing this contract depends on — the migration is a new + commit, not a rewrite of history. +- **What the pass does not cover, it names.** Types with no repository write + path are reported as `skipped` with the reason, never counted as done. + +## Addendum (2026-08-01b) — flows reach the finish line too (#4454) + +The pass above initially skipped `flow` rows, which was the largest hole in it: +the graduated flow-node conversions are where the most stored dialect lives. +Closing it needed three decisions. + +- **One canonicalization policy, two shapes.** + `AutomationEngine.canonicalizeStoredFlow` is now the single implementation and + `registerFlow` calls it, so the load seam and the migration cannot disagree + about what canonical means. It returns `parsed` (for execution — schema + defaults materialized) and `storable` (for persistence). +- **`storable` excludes schema defaults, and this is load-bearing.** Measured, + not assumed: driving a pre-17 flow through parse + the region pass *removes* + nothing (`FlowSchema` is strict since #4001 — an unknown key throws rather + than being dropped, so the `graftNormalizedOperators` precedent does not + transfer) and *adds* only defaults: `version`, `runAs`, per-edge `type` / + `isDefault`. Persisting a default the author never wrote would pin every + migrated row to today's value while untouched rows follow tomorrow's — two + populations with different behaviour, which is the drift this pass exists to + remove. So the write-back is conversions plus the schema's `condition` + envelopes, and nothing else. +- **The engine is borrowed, not started.** `AutomationServicePlugin` gains + `armRuntime: false`: built-in nodes installed and `automation:ready` fired + (the registry must be COMPLETE, or the conflict guard reads a live custom node + type as unowned and rewrites over it), then a hard stop before anything is + armed — no flow registered, no trigger or schedule bound, no connector + materialized, no suspended run resumed. `registerFlow` arms triggers as a side + effect, so skipping only the boot pull would not have been enough; the + `kernel:ready` and `metadata:reloaded` re-syncs are skipped for the same + reason. A migration process must not become a second server. + +A refused rename — the guard firing because the old token is a live name owned +by something else — fails that row loudly with the token and its owner. Never a +silent skip, never a clobber; that is the whole reason the guard exists. + +## Addendum (2026-08-01c) — "strictly shrinking" was false for flows (#4498) + +The bullet above claims new rows are always canonical, *therefore* the stored +pass is a strictly shrinking concern. `duplicatePackage` was a live producer +contradicting it: it canonicalizes each source row before re-saving, but through +`convertStoredItem`, which returns `flow` bodies untouched. `FlowNodeSchema.config` +is an open `z.record`, so a pre-17 body sailed through `saveMetaItem`'s gate and +landed verbatim in a brand-new row. An operator could run the migration, get a +clean report, duplicate a package, and be back to pre-protocol rows — with the +report still saying protocol N until the next run. + +- **The capability was already reachable; only the wiring was missing.** The + protocol is constructed with an accessor for the kernel's service table (the + same one `analytics` and `package` are read from), and the automation service + registers under `automation`. `resolveFlowCanonicalizer` reads + `canonicalizeStoredFlow` off it. So the fix is not new plumbing per call site + — it is one private resolver that every caller running next to a live engine + shares. +- **The explicit hook becomes an override, not a requirement.** + `migrateStoredMetadata`'s `canonicalizeFlow` defaults to the resolver, so the + CLI stopped passing one (it boots the inert engine into the same kernel, so + both routes reached the same instance — two routes to one capability is how + they drift). The parameter stays for callers with no registry and for testing + the flow branch without an engine. +- **Resolution is lazy, per call.** Plugin init order does not guarantee + `automation` is in the table when the protocol is assembled — the CLI adds it + after ObjectQL by design — so caching `undefined` from a too-early read would + disable flow canonicalization for the life of the process. +- **The failure posture matches #4454's.** A refused rename fails that item into + `duplicatePackage`'s existing `failed[]` naming the token, rather than copying + the un-renamed body: producing exactly the row this fix exists to prevent is + the one outcome worse than failing the copy. A flow that cannot canonicalize + at all fails the same way. With **no** engine reachable (a control-plane or + metadata-only host) the source body is copied as-is — no worse than the source + row already is, and failing an unrelated duplication over it would be its own + regression. +- **Reads were not changed.** `getMetaItems` / `getMetaItem` / + `getMetaItemLayered` / `loadMetaFromDb` still skip flows; they are reads, + covered by `registerFlow` canonicalizing at execution, and are not producing + bad data. Duplication was the one that *writes*. The resolver is the seam they + would adopt if that changes. + +The premise is restored rather than restated: the stored pass shrinks because +every write path now canonicalizes, not because the sentence says so. diff --git a/docs/audits/2026-06-react-blocks-conformance.md b/docs/audits/2026-06-react-blocks-conformance.md index 344e181856..0380e841ec 100644 --- a/docs/audits/2026-06-react-blocks-conformance.md +++ b/docs/audits/2026-06-react-blocks-conformance.md @@ -1,19 +1,43 @@ # Spec ↔ frontend conformance — react blocks (2026-06) +> ## ⚠️ Correction (2026-08-01, #4472) +> +> **This audit did not answer the question it was asked.** The question was +> whether the components *implement* the spec's props. What the check measures — +> then and now — is whether two **declarations** agree: the spec zod schema's +> props, and the `inputs` the objectui *registry config* declares. Both sides are +> declarations (`manifestFromConfigs` copies `config.inputs` verbatim); no +> renderer is involved anywhere in it. +> +> The assumption below that carried the mistake is stated in "How to read this": +> *"The component reads its full config from the spec schema at render."* Nothing +> here established that, and for the `record:*` family it was false — #4413 found +> four blocks publishing `objectName`/`recordId` that no renderer read, which +> this check reported as zero divergence for the whole life of the defect. +> +> The check is now `check:react-declaration-parity` and says so in its own output. +> The findings table below is still accurate **as a declaration diff** — read +> "frontend-only" as "the registry declared an input the spec did not", not as +> "the component accepts it". Evidence about the render path comes from +> objectui's `public-block-binding-reach.test.tsx`; see the ADR-0082 addendum. + **Question** (raised in review): we can't guarantee the frontend (objectui) components actually implement the props the backend spec protocol declares — should we confirm it? -**Answer**: confirmed — they diverge. Below is the first run of the conformance -check (`packages/spec/scripts/check-react-blocks-conformance.ts`), comparing the -spec schemas referenced by `REACT_BLOCKS` against the live objectui -registry-inputs manifest (`sdui.manifest.json`). +**Answer**: confirmed — they diverge. Below is the first run of the check +(`packages/spec/scripts/check-react-blocks-declaration-parity.ts`, then named +`check-react-blocks-conformance.ts`), comparing the spec schemas referenced by +`REACT_BLOCKS` against the live objectui registry-inputs manifest +(`sdui.manifest.json`). ## How to read this The registry `inputs` are the **designer palette** — a curated subset the visual editor exposes — NOT the component's full prop surface. The component reads its -full config from the spec schema at render. So: +full config from the spec schema at render. *(⚠️ That last sentence is the +assumption #4472 retracted — see the correction above. It was an expectation, not +something this audit measured.)* So: - **frontend-only** props (component declares an input the spec does not) are the reliable, actionable divergence: the spec is missing them or they are an @@ -55,7 +79,7 @@ full config from the spec schema at render. So: ``` # produce a manifest from the live registry (objectui), then: -MANIFEST=/path/to/sdui.manifest.json pnpm --filter @objectstack/spec check:react-conformance +MANIFEST=/path/to/sdui.manifest.json pnpm --filter @objectstack/spec check:react-declaration-parity # add --strict to fail on divergence (once triaged). ``` @@ -66,19 +90,21 @@ console-build time. So the conformance check is wired in as a **baseline ratchet at the one place the manifest is produced for free: `scripts/build-console.sh`, right after it dumps `sdui.manifest.json` from the freshly-built console registry. -- The accepted state lives in `packages/spec/react-conformance.baseline.json` - (per block: the frontend-only prop set + whether the block is missing). +- The accepted state lives in `packages/spec/react-declaration-parity.baseline.json` + (per block: the registry-only input set + whether the block is missing). - `--baseline ` compares the current dump against it and reports **only - regressions**: a component exposing a NEW undocumented prop, or a + regressions**: a registry config declaring a NEW undocumented input, or a previously-present block vanishing. The soft `spec-only` signal is not gated. -- In `build-console.sh` it runs **warn-only** (never fails the console build). - Use `--strict` to gate intentionally (exit 1 on regression). +- It runs `--strict`, so a regression fails the run. *(It shipped warn-only — + the exit code swallowed behind a `⚠` — which #4472 corrected along with the + name: "divergence recorded" and "divergence stopped" were being read as the + same green build.)* ``` -# accept the current frontend state as the new baseline (after an intentional change): +# accept the current registry state as the new baseline (after an intentional change): MANIFEST=/path/to/sdui.manifest.json \ - pnpm --filter @objectstack/spec check:react-conformance \ - --baseline react-conformance.baseline.json --update + pnpm --filter @objectstack/spec check:react-declaration-parity \ + --baseline react-declaration-parity.baseline.json --update ``` When the ratchet flags a new frontend-only prop, the fix is one of: declare it in diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 7e3e3c46a0..c7db571910 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -27,11 +27,11 @@ must explicitly `.strip()` back, because `.extend()` inherits `.strict()`. ## Standard wiring -`strictUnknownKeyError` in `shared/suggestions.zod.ts` (generalized from the -#3746 hand-rolled map) is the one factory every strict authoring schema wires: +`strictObject` in `shared/strict-object.ts` is the one call a strict authoring +schema needs: ```ts -z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases, guidance, history }) }).strict() +lazySchema(() => strictObject({ surface, history, aliases?, guidance? }, { ...shape })) ``` - `aliases` — semantic near-misses edit distance cannot reach (`visibleWhen` → @@ -40,8 +40,24 @@ z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases, rejection carries the upgrade — AGENTS.md Post-Task Checklist #3) and wrong-layer pointers (`apiOperations` is response-side; `objectName` belongs on the start node). -- Key lists live beside the schema and are **drift-guarded by tests** (an - "accepts every declared key" probe), because the schema body is lazy. +- **Both are optional.** A schema with neither still names the surface, echoes + the offending key, and suggests the closest declared one. Curation is an + upgrade, not a precondition — and treating it as a precondition is part of + why this ratchet moved as slowly as it did. + +**No key list, and no drift probe.** Earlier steps hand-transcribed a +`const X_KEYS = [...] as const` beside each schema and pinned it with an +"accepts every declared key" test, because the schema body is lazy. That was 34 +key arrays and 16 probe files with most of the surface still ahead — and it was +never necessary: `knownKeys` feeds only the edit-distance fallback, and the +shape object is right there at the call site. `strictObject` reads the keys from +`shape`, so the two copies become one and the probe has nothing left to catch. +`extraKeys` covers the one case the shape cannot see: a base `.extend()`ed +elsewhere. + +`strictUnknownKeyError` stays exported for the schemas that cannot use the +helper — notably `z.lazy()` discriminated unions, whose variants each need +their own key set. Every ratchet step ships only with the empirical zero-breakage pass: full `@objectstack/spec` suite + `tsc`, downstream consumer suites, and @@ -110,9 +126,77 @@ dropped at parse, and nothing failed. now catches it was directed, with the platform's authority, at a slot where the same mistake is silent again: `config: { hostname: … }` is stripped and the datasource connects on localhost — #4001's original bug verbatim, one - level down. Corrected to name the per-driver shape instead of promising a - gate; enforcement is #4410. **A wrong instruction is worse than none**, and - worst for an AI author, whose only signal is whether the parse complained. + level down. First corrected to name the per-driver shape instead of promising + a gate; **#4410 then built the gate**, so the prescription makes a validation + claim again and the claim is true. **A wrong instruction is worse than + none**, and worst for an AI author, whose only signal is whether the parse + complained. + + Two things #4410 had to fix before the sentence was safe to write, both + instructive beyond this schema. The prescription has to name the key the + contract *lands on*, not the one the author typed — pointing a misplaced + `user` at `config: { user: … }` when postgres spells it `username` would + swap a one-step correction for a two-step one. And a gate over `config` + means every key inside it now claims to be honoured, which forced a per-key + audit against the code that reads them: `indexes` / `maxRecordsPerObject` + (memory) were removed as inert, while `datasource.pool`, `schemaMode`, + postgres `schema` / `applicationName` / `statementTimeout` and mongo + `password` / `authSource` / `options` were **wired**, having been declared + and dropped on the floor. Enforcing a contract and honouring it are the same + task from two directions. +8. **Three more registered types could not represent their own ADR-0010 + protection envelope** — `seed`, `doc` and `validation`, found by applying the + registered-type lens above. Exactly the gap that made `permission` return a + hard 422 on the ADR-0094 overlay path (entry 2) and that `position` carried + until step 2: `MetadataPlugin`'s artifact loader stamps `_packageId` / + `_provenance` on **every** registered type, and `getMetaItemLayered` → + `saveMetaItem` round-trips a body carrying them, so an undeclared envelope was + stripped on every parse. Declared on `seed` and `doc` as part of closing them; + `validation` still carries it (its union shape defers the conversion). + + Worth noting how it kept recurring: this was the **fourth** occurrence of one + defect, found four times by four different routes, because nothing checked + the invariant directly. So it is checked now — + `kernel/metadata-type-schemas.test.ts` asserts it over the whole registry. + + **It found two more on its first run.** `hook` and `datasource` had both gone + `.strict()` in the #4001 data step *without* declaring the envelope, so both + were in the worst class — rejecting their own loader's output, a live hard 422 + on the ADR-0094 overlay path, sitting on `main`. Three prior hand-searches for + exactly this defect had walked past them. That is the argument for writing the + check in one line: **finding the same defect repeatedly by hand is evidence + the check is missing, not evidence the search worked.** + + The check separates the two severities, because they are not the same bug: + *rejecting* the envelope is live breakage and is asserted unconditionally with + no exemption list; *not declaring* it silently loses protection metadata on + round-trip and is tracked with a debt list — and each entry there becomes a + rejection the day its schema is closed. +9. **And then that check turned out to be hollow — one change after this file + recorded the same lesson about the gate above.** Its declaration half probed + each schema with one generic body and asked whether `_packageId` survived. A + type whose required fields that body did not satisfy failed for unrelated + reasons and the assertion returned early, so **24 of 25 registered types took + that early return**. Only `field` was ever really checked, and the suite + reported green. + + Rewritten to walk the schema *structurally* — unwrapping `lazy` / `pipe` / + `optional` / `default`, expanding unions — which needs no valid instance and + therefore cannot skip. Two guards keep it honest: a type the walker cannot + resolve is a hard failure (the walker going quiet is precisely when the test + would otherwise stop covering something), and the debt list carries a reverse + pin that fails when an entry is fixed, so the list cannot outlive its debt. + + It then found **8** undeclared envelopes rather than 1 — `action`, `book`, + `field`, `job`, `mapping`, `page`, `translation`, `validation`. `job` and + `book` were closed immediately; 6 remain. + + Three occurrences now of one pattern, in three different instruments: the + ledger gate's non-recursive directory walk, the strip probe's early return, + and (from the other direction) `strictObject(` not matching the site count. + Each was a measuring tool reporting completeness it did not have. **The rule + this file keeps re-deriving: before trusting a green check, make it go red on + something you know is there.** This is the empirical argument for the ratchet: the inference "no metadata in the repo carries unknown keys" was **false three times over**, and only the @@ -136,17 +220,18 @@ block) when `position` joined the ratchet. ## File-level triage — the five authorable directories -Site counts are `z.object(` occurrences per file (2026-07-30, this branch). +Site counts are object sites — `z.object(` or `strictObject(` — per file (2026-07-30, this branch). Classification is per the rule above; **(p)** marks a provisional call made from the file's exports/JSDoc rather than a full read — verify before tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). -### `ui/` — 197 sites +### `ui/` — 200 sites | File | Sites | Class | Note / next action | |---|---|---|---| | `action.zod.ts` | 9 | authorable | param schema strict (#3746); remaining blocks ride later steps | -| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks | +| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | +| `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open**: objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so `.passthrough()` is the honest mirror and strictness there would reject valid config — same call as `dashboard.zod.ts`'s widget `config`. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | | `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | | `theme.zod.ts` | 14 | authorable (p) | authored themes | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | @@ -158,7 +243,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | | `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | -### `data/` — 158 sites +### `data/` — 160 sites | File | Sites | Class | Note | |---|---|---|---| @@ -169,8 +254,9 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `field.zod.ts` | 11 | authorable | partially strict | | `filter.zod.ts` / `query.zod.ts` | 11+5 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Four more left in #4286 with the `joins`/`windowFunctions` removals: `JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, and `WindowSpecSchema`'s two blocks (outer + `frame`) were deleted with their clusters. Class unchanged | | `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts | -| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes — see the `driver/` row below). This row used to add "the driver's own `configSchema` validates them"; **it does not, and never did** — corrected, and the gap is #4410. Which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing | -| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+2 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`, pool sizes). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these nine sites sat outside the map while the map reported full coverage. Authorable by the rule, but they are **contract-only exports today** — nothing parses `datasource.config` against them and both `*DriverSpec.configSchema` literals are `{}` (#4410). Strictness here would therefore enforce nothing; this row is blocked on #4410 giving it a parse site, not on a verification pass | +| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | +| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | +| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | 1+2 | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | | `analytics.zod.ts` | 8 | mixed (p) | | | `document.zod.ts` | 8 | wire (p) | | | `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | @@ -178,13 +264,12 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `external-catalog.zod.ts` | 4 | wire (p) | | | `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | | -### `automation/` — 99 sites +### `automation/` — 88 sites | File | Sites | Class | Note | |---|---|---|---| | `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) | | `sync.zod.ts` / `etl.zod.ts` | 12+10 | authorable (p) | authored pipelines — **candidates** | -| `trigger-registry.zod.ts` | 11 | mixed | descriptors are code-registered (wire-ish); bindings authored | | `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged | | `state-machine.zod.ts` | 7 | authorable (p) | | | `control-flow.zod.ts` | 6 | authorable (p) | validated structurally by `validateControlFlow` | @@ -193,10 +278,12 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `node-executor.zod.ts` | 4 | wire | executor contract | | `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them; candidate once the executors' own drift is verified | | `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. Same candidacy note as `io-node-config` | -| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Contract exports only — nothing parses node config with them yet, so strictness candidacy follows `io-node-config` | +| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Since #4343 `script` and `subflow` ARE parsed at execute time (`parse-config.ts`) — `script` once retiring its `actionType` branches left it flat — so strictness candidacy now follows `io-node-config` on the same terms rather than being moot; `decision` stays export-only | | `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) | | `flow-function.zod.ts` | 1 | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. Candidate on the same verify-first rule as its `*-node-config` neighbours | +`trigger-registry.zod.ts` had a row here (11 sites, "mixed — descriptors are code-registered (wire-ish); bindings authored") until #4499 deleted the file: all 11 sites were the third connector-vocabulary declaration (`ConnectorSchema` / `Authentication*` / `Operation*` / `ConnectorInstance`), and the old row's classification was optimistic twice over — nothing was ever code-registered against these descriptors and no binding was ever authored. The engine registers against `integration/connector.zod.ts` (ADR-0097), which keeps its own row. + ### `security/` — 20 sites | File | Sites | Class | Note | @@ -260,6 +347,20 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). downstream risk is the lowest on the board); it is simply unstarted. If the step-1 question comes back "nothing is reporting", start here instead. +Done in the registered-types batch: `strictObject` (`shared/strict-object.ts`) +replaced the four-part wiring recipe, and `seed` + `doc` became the first two +conversions built on it — chosen by the registered-type lens above rather than +by directory, so both are provably parsed as well as provably authored. Both +also had to declare the ADR-0010 envelope, and the invariant test written in the +same pass found `hook` and `datasource` rejecting it outright on `main` +(findings log, entry 8). The ledger's site-counting method grew `strictObject(` +in the same change, because the gate failed on the first conversion when it did +not. + +Deferred from that batch: `validation` — a `z.lazy()` discriminated union whose +variants `.extend()` a shared base, so each variant needs its own key set rather +than one `strictObject` call. It still carries the envelope gap. + Done in step 2: `security/rls.zod.ts` + `security/sharing.zod.ts` strict; `PositionSchema` strict with the protection envelope declared (closing the known sibling gap below). @@ -338,10 +439,15 @@ the app step's `ACCOUNT_APP.defaultOpen` came from exactly this class of check. Liveness Check workflow) holds the two claims here that are mechanically checkable, so this map cannot go stale in silence again: -- **Site counts.** The method is stated above — `z.object(` occurrences per file - — so every number in the triage tables is verifiable. A count that no longer - matches means schemas were added or removed under a `Class` verdict nobody - re-examined. Touching a file forces you back through this ledger. +- **Site counts.** The method is stated above — `z.object(` or `strictObject(` + occurrences per file — so every number in the triage tables is verifiable. A + count that no longer matches means schemas were added or removed under a + `Class` verdict nobody re-examined. Touching a file forces you back through + this ledger. `strictObject(` had to join the count the moment the helper + existed: counting only `z.object(` would have made every conversion look like + surface *disappearing*, so "this directory got solved" and "this directory got + deleted" would produce the same number. The gate caught that itself on the + first conversion. - **Coverage.** Every `*.zod.ts` in a triaged directory that HAS sites must have a row. A new one is undeclared surface. The walk is **recursive**; nested files are declared by their path relative to the section directory diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index c6b7cf8168..9dfa080f4e 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -156,6 +156,10 @@ The #4286 close-out settles the remaining three. `having` is ENFORCED, not remov The same kind of retirement covers `wait`'s timeout pair (#4158). `waitEventConfig.onTimeout` had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever happened, while its `.default('fail')` stamped a decision nothing made onto every wait node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only reader used it as the timer DURATION when `timerDuration` was absent: it did something, just not what it said. Together they declared a timeout `wait` does not have — the run resumes when its timer elapses or its signal arrives, never on a deadline. Rather than retrofit an implementation to fit two keys that happened to be declared, the pair is retired and real timeout semantics are left to be built to a requirement. `timeoutMs` converts to `timerDuration` (stringified — the target is `z.string()` and `parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); with `timerDuration` already set it is dropped, having been dead metadata. Like the other keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the load path: absorbing them silently would let an author keep believing they configured a timeout. +Closing the same audit on the data side, `datasource.readReplicas` is removed (#4468). It described replica connections nothing ever opened: `ConnectableDatasource` and `DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the key, and no query path distinguishes a read from a write — read/write splitting does not exist in the platform, so every statement always went to the primary. A lossless delete with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` at it. Notable as the case that shows how a key gets MORE convincing as it stays dead: #4410, closing the datasource-config gap, taught the schema to validate each replica entry against the declared driver's config contract, so sources written in between carry replica blocks that were genuinely checked — precise hosts, correct port types, typos rejected. Precision applied to an inert slot reads as evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for rigor. Retired from the load path with the rest of the keys that misdescribed themselves. + +The `script` flow node converges on its one real path (#4343). It had four ways to name what it ran and only one of them ran anything: `config.actionType: 'email' | 'slack'` were logger-backed stubs that wrote a line, reported success and delivered nothing under any configuration — with `config.template` / `.recipients` / `.variables` feeding a message no channel ever sent; inline `config.script` was recognized and never executed (the built-in runtime has no server-side JS sandbox), so the node warned and no-op'd; and every other `actionType` value was shorthand for a registered-function name, a second spelling of `config.function`. All five keys are retired and `function` becomes required, which is also what finally made the contract PARSEABLE: while the legal key set depended on `actionType`, a flat parse would either reject valid shapes or wave everything through, so `script` (with `subflow`) now runs through the same execute-time contract parse #4277 gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what it meant — unless `function` is already set, in which case it was dead metadata the executor never reached. The other four are dropped outright: nothing read them, so there is no value to preserve, and rebuilding the intent is an authoring decision the tombstones prescribe per branch (a `notify` node for mail — it delivers through the messaging service, the in-app inbox by default and real email once `@objectstack/plugin-email` is installed; a `connector_action` with the Slack connector, or an `http` node posting to a webhook, for Slack; a registered function for an inline body). Retired from the load path for the same reason as the rest: absorbing `actionType: 'email'` silently would let an author keep believing the flow sends mail. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -183,6 +187,8 @@ The same kind of retirement covers `wait`'s timeout pair (#4158). `waitEventConf | `skill-trigger-phrases-removed` | `skill.triggerPhrases` | skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection) | retired — `migrate meta` only | | `stack-api-require-auth-removed` | `stack.api.requireAuth` | stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963) | retired — `migrate meta` only | | `flow-node-wait-timeout-keys-removed` | `flow.node.waitEventConfig` | waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158) | retired — `migrate meta` only | +| `datasource-read-replicas-removed` | `datasource.readReplicas` | datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it) | retired — `migrate meta` only | +| `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) @@ -216,6 +222,9 @@ The same kind of retirement covers `wait`'s timeout pair (#4158). `waitEventConf - **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it. +- **`workflow-service-slot-retired`** — `CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow` → the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation + - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451. + - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery `routes.workflow` / `services.workflow`; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent). --- diff --git a/docs/v17-docs-sweep.md b/docs/v17-docs-sweep.md index fd497df566..af81f6636d 100644 --- a/docs/v17-docs-sweep.md +++ b/docs/v17-docs-sweep.md @@ -58,6 +58,7 @@ merely *references* changed code, use the `docs-accuracy-audit` workflow the | Node `18` as a floor | states an out-of-date prerequisite | engines-node-22 | | `PortalSchema`, `AuditConfig`, Capabilities-descriptor cluster, `FeatureFlagSchema`, `DEFAULT_*_ROUTES`, report `aria`/`performance`, `ReportColumn/GroupingSchema` | teaches a pruned cluster | prune-* family | | `GetTranslationsRequest` `namespace`/`keys` filters | teaches the dropped filters | i18n-translations-request-drop-phantom-filters | +| `` `@objectstack/spec` 18 `` (any v17 removal dated to **18**) | dates a removal that ships in **17** to the next major — the reader plans for it a release late and their upgrade breaks. Check the *number*, not just the surface name: `spec-changes.json` `toMajor` is the arbiter | #4286, #3963 | ## Run log @@ -108,4 +109,27 @@ merely *references* changed code, use the `docs-accuracy-audit` workflow the - **Not yet swept:** `examples/**` inline prose and `docs/**` (internal); lower-priority — user-facing `content/docs` + `skills` covered first. +### 2026-08-01 — run 3 (version-number pass, #4476) + +- **Watermark:** framework `0f9faa2` (origin/main, post-#4489). +- **Fingerprint added:** the `@objectstack/spec` 18 row above. Runs 1-2 matched on + *surface names* and so read straight past a passage that named the right surface + and the wrong release. The number is the actionable half of a removal notice. +- **Fixed (drift → corrected):** 17 passages dating v17 removals to 18 → 17. + `spec-changes.json` gives `toMajor: 17` for all five surfaces involved, and this + tree is `17.0.0-rc.1` / `PROTOCOL_VERSION = '17.0.0'` with the keys already + `[RETIRED]` in `authorable-surface.json` — a removal cannot already be retired in + a 17 build and also ship in 18. + - `protocol/objectql/query-syntax.mdx` (5) · `data-modeling/queries.mdx` (4) · + `deployment/troubleshooting.mdx` (1) — the #4286 query surfaces. + - `skills/objectstack-query/SKILL.md` (3), `rules/aggregation.md` (1), + `rules/pagination.md` (1) — same wrong number in the **agent-facing** skill, + which #4476's fingerprint list did not cover. Highest-leverage of the set: an + agent authoring queries reads these as ground truth. + - `releases/implementation-status.mdx` (2) — same error shape on a *different* + change, `api.requireAuth` (#3963), also `toMajor: 17`. +- **Method note for the next run:** #4476 listed nine locations; a bare-pattern grep + found seventeen. Grep the pattern repo-wide (including `skills/`), do not work a + fingerprint list file-by-file. + diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 20fffd2d59..e705313879 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -56,11 +56,20 @@ export default defineStack({ requires: ['ui', 'automation'], // Infrastructure + // + // No `datasourceMapping`. These two datasources are declared to exercise the + // metadata surface, not to route anything: both are `:memory:`, and every + // object here has always been served by the host's `default` store. The + // mapping that used to sit here (`namespace: 'crm'` + `default: true` → + // `crm_primary`) was decorative — `namespace` is deprecated and no object + // sets it, and `crm_primary` had no live driver, so routing fell through to + // `default`. #4462 stopped routing from falling through, because that + // fall-through is what silently put a mapped object's rows in a different + // database than the one it declared. Deleting the rule is what keeps this + // example's behavior IDENTICAL under the new posture; keeping it would move + // the whole app — platform objects included — onto an in-memory database + // that is empty on every boot. datasources: [CrmDatasource, CrmAnalyticsDatasource], - datasourceMapping: [ - { namespace: 'crm', datasource: 'crm_primary' }, - { default: true, datasource: 'crm_primary' }, - ], // Internationalisation translations: [CrmTranslationBundle], diff --git a/examples/app-crm/src/datasources/crm.datasource.ts b/examples/app-crm/src/datasources/crm.datasource.ts index 09c210a7f0..14b5b58253 100644 --- a/examples/app-crm/src/datasources/crm.datasource.ts +++ b/examples/app-crm/src/datasources/crm.datasource.ts @@ -22,6 +22,10 @@ export const CrmDatasource = defineDatasource({ /** * Read-replica for analytics queries — demonstrates datasource routing. + * + * `readOnly` is a datasource CAPABILITY, not sqlite config. It sat inside + * `config` here until #4410 gave that slot a gate — a key no driver read, so + * the "read replica" was writable while every signal said it was not. */ export const CrmAnalyticsDatasource = defineDatasource({ name: 'crm_analytics', @@ -29,6 +33,8 @@ export const CrmAnalyticsDatasource = defineDatasource({ driver: 'sqlite', config: { filename: ':memory:', + }, + capabilities: { readOnly: true, }, active: true, diff --git a/examples/app-crm/src/flows/convert-lead.flow.ts b/examples/app-crm/src/flows/convert-lead.flow.ts index bc90e584cb..f8106daa3c 100644 --- a/examples/app-crm/src/flows/convert-lead.flow.ts +++ b/examples/app-crm/src/flows/convert-lead.flow.ts @@ -13,8 +13,8 @@ import { defineFlow } from '@objectstack/spec'; * children, atomically), and resumes the run with the new record's id bound to * `config.idVariable`. * - * start → get_lead → decision (already converted?) - * → screen_already_converted (abort path) + * start → get_lead → decision (already converted?) — exclusive, exactly one: + * → screen_already_converted (abort path, when status == 'converted') * → screen_account (Step 1 — full Customer form → account_id) * → screen_opportunity (Step 2 — full Opportunity form WITH product * line-items grid, prefilled account → opportunity_id) @@ -67,16 +67,19 @@ export const ConvertLeadScreenFlow = defineFlow({ }, // ── 3. Guard: already converted? ────────────────────────────────────── + // A plain exclusive gateway: the branching lives on the OUT-EDGES (`e3a`'s + // CEL condition, `e3b`'s `isDefault`) — ONE mechanism, not two. + // + // It used to ALSO declare `config.conditions` with its own labels, and that + // guard did not guard (#4414). Those labels (`'Yes — already converted'` / + // `'No — proceed'`) matched no out-edge label (`'Yes'` / `'No'`), so the + // branch the node computed was dropped and every out-edge was considered + // instead — and `e3b` was unconditional, so an already-converted lead got + // the abort screen AND walked straight into the wizard behind it. { id: 'check_converted', type: 'decision', label: 'Already Converted?', - config: { - conditions: [ - { label: 'Yes — already converted', expression: "{lead_record.status} == 'converted'" }, - { label: 'No — proceed', expression: 'true' }, - ], - }, }, // ── 3a. Already-converted abort screen ──────────────────────────────── @@ -155,9 +158,13 @@ export const ConvertLeadScreenFlow = defineFlow({ edges: [ { id: 'e1', source: 'start', target: 'get_lead', type: 'default' }, { id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' }, - // guard branches + // Guard branches — mutually exclusive. `e3a` carries the CEL predicate; + // `e3b` is the BPMN default flow (`isDefault`), traversed ONLY when no + // conditional sibling matched. Without that marker `e3b` is an ordinary + // unconditional out-edge and runs on every pass, wizard and abort screen + // together (#4414). { id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' }, - { id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', label: 'No' }, + { id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', isDefault: true, label: 'No' }, { id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' }, // main path — full Customer form → full Opportunity form → link { id: 'e4', source: 'screen_account', target: 'screen_opportunity', type: 'default' }, diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index f5ddb1d9ec..bad1354a4e 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -204,6 +204,15 @@ export default defineStack({ // Logic flows: allFlows, + // Named callables a `script` flow node invokes (#1870). Since #4343 that is + // the ONLY thing a script node does, so this map is what makes one runnable. + // A flow function is PURE: it takes `inputs`, RETURNS a value, and a later + // declarative node uses or persists it — it does no data I/O of its own + // (#4396), which is why it needs no `effect` declaration here. + functions: { + summarizeCompletedTask: ({ input }: { input: Record }) => + `Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`, + }, jobs: allJobs, emailTemplates: allEmails, // Declarative REST endpoints (object_operation + flow) — the metadata diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 5b5d6982eb..3c467ca620 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -6,14 +6,28 @@ import { ApproverBindingsFlow } from './approver-bindings.flow'; /** * Task Completed → Notify — an autolaunched, record-triggered flow that fires - * when a task transitions to Done and emails the project owner. + * when a task transitions to Done, composes a one-line summary in a registered + * function, and notifies the assignee. + * + * It also carries the two node types #4343 sorted out from each other: + * + * - **`script`** calls a registered function (`defineStack({ functions })`) and + * binds its RETURN value to a flow variable. That is the whole of what the + * node does now — the `actionType` side effects it used to offer were + * logger-backed stubs that delivered nothing. + * - **`notify`** is the real delivery mechanism: it hands the messaging service + * the notification (the in-app inbox by default, email once + * `@objectstack/plugin-email` is installed). */ export const TaskCompletedFlow = defineFlow({ name: 'showcase_task_completed', label: 'Notify on Task Completed', - description: 'Emails the project owner when a task is marked Done.', + description: 'Summarizes a completed task in a registered function, then notifies its assignee.', type: 'autolaunched', status: 'active', + variables: [ + { name: 'summary', type: 'string', isInput: false, isOutput: false }, + ], nodes: [ { id: 'start', @@ -26,23 +40,38 @@ export const TaskCompletedFlow = defineFlow({ }, }, { - id: 'notify', + id: 'summarize', type: 'script', - label: 'Send Completion Email', + label: 'Compose Summary', config: { - actionType: 'email', - inputs: { - to: '{record.project.owner}', - subject: '✅ Task done: {record.title}', - template: 'showcase_task_done_email', - }, + // Registered in `defineStack({ functions })` — see objectstack.config.ts. + // A flow function is PURE: it takes `inputs`, RETURNS a value, and a + // later declarative node uses or persists it (#4396). + function: 'summarizeCompletedTask', + inputs: { title: '{record.title}', priority: '{record.priority}' }, + outputVariable: 'summary', + }, + }, + { + id: 'notify', + type: 'notify', + label: 'Notify the assignee', + config: { + // A field ON the record: the flow record carries `project` as a scalar + // id, so `{record.project.owner}` would resolve to an empty string. + recipients: '{record.assignee}', + title: '✅ Task done: {record.title}', + message: '{summary}', + sourceObject: 'showcase_task', + sourceId: '{record.id}', }, }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ - { id: 'e1', source: 'start', target: 'notify' }, - { id: 'e2', source: 'notify', target: 'end' }, + { id: 'e1', source: 'start', target: 'summarize' }, + { id: 'e2', source: 'summarize', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, ], }); @@ -211,12 +240,12 @@ export const BudgetApprovalFlow = defineFlow({ // load, but the showcase should demonstrate the declared spelling. waitEventConfig: { eventType: 'signal', signalName: 'budget_revision' }, }, - { - id: 'needs_exec', - type: 'decision', - label: 'Budget Above $500k?', - config: { condition: 'budget > 500000' }, - }, + // A plain exclusive gateway: the predicate is on the out-edges (e4/e5). + // It also carried `config.condition` — inert on every node but `start`, and + // the comment on those edges already said so. Keeping a copy that nothing + // reads is the shape #4414 is about, so it is gone; `os validate` reports + // it as `flow-inert-node-condition`. + { id: 'needs_exec', type: 'decision', label: 'Budget Above $500k?' }, { id: 'exec_review', type: 'approval', @@ -236,10 +265,11 @@ export const BudgetApprovalFlow = defineFlow({ { id: 'e1', source: 'start', target: 'manager_review' }, { id: 'e2', source: 'manager_review', target: 'needs_exec', label: 'approve' }, { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' }, - // Decision branching is edge-condition driven (flow spec): the engine - // routes a decision node by evaluating each out-edge's `condition`. Carry - // the predicate on the edges (the node `config.condition` alone is not - // evaluated by the engine), so budgets ≤ $500k skip the executive step. + // Decision branching is edge-condition driven: the engine routes a decision + // by evaluating each out-edge's `condition`, so the predicate lives here and + // budgets ≤ $500k skip the executive step. These two are complementary, so + // exactly one runs; the other correct spelling is one `condition` plus + // `isDefault: true` on the fallback edge (#4414). { id: 'e4', source: 'needs_exec', target: 'exec_review', label: 'true', condition: 'budget > 500000' }, { id: 'e5', source: 'needs_exec', target: 'approved', label: 'false', condition: 'budget <= 500000' }, { id: 'e6', source: 'exec_review', target: 'approved', label: 'approve' }, @@ -818,15 +848,13 @@ export const BatchRemindersFlow = defineFlow({ nodes: [ { id: 'send_reminder', - type: 'script', + type: 'notify', label: 'Send Reminder', config: { - actionType: 'email', - inputs: { - to: '{task.owner.email}', - subject: 'Reminder ({taskIndex}): {task.title}', - template: 'showcase_task_reminder_email', - }, + recipients: '{task.owner}', + title: 'Reminder ({taskIndex}): {task.title}', + sourceObject: 'showcase_task', + sourceId: '{task.id}', }, }, ], @@ -875,30 +903,37 @@ export const FanOutNotifyFlow = defineFlow({ config: { branches: [ { - name: 'Email the owner', + name: 'Notify the owner', nodes: [ { - id: 'email_owner', - type: 'script', - label: 'Email Owner', + id: 'notify_owner', + type: 'notify', + label: 'Notify Owner', config: { - actionType: 'email', - inputs: { to: '{record.project.owner}', subject: '✅ Done: {record.title}' }, + recipients: '{record.assignee}', + title: '✅ Done: {record.title}', + sourceObject: 'showcase_task', + sourceId: '{record.id}', }, }, ], edges: [], }, { + // Slack is a CONNECTOR, not a notify channel (#4343): post through + // an incoming webhook, or a `connector_action` with the Slack + // connector. The retired `script` + `actionType: 'slack'` shape + // logged a line and delivered nothing. name: 'Post to Slack', nodes: [ { id: 'slack_post', - type: 'script', + type: 'http', label: 'Slack Notify', config: { - actionType: 'slack', - inputs: { channel: '#tasks', text: 'Task done: {record.title}' }, + url: 'https://hooks.slack.com/services/T000/B000/XXXX', + method: 'POST', + body: { channel: '#tasks', text: 'Task done: {record.title}' }, }, }, ], @@ -1122,12 +1157,12 @@ export const ProjectEscalationFlow = defineFlow({ branches: [ { name: 'Owner', - nodes: [{ id: 'alert_owner', type: 'script', label: 'Alert Owner', config: { actionType: 'email', inputs: { to: '{record.owner}', subject: '🔴 Critical: {record.name}' } } }], + nodes: [{ id: 'alert_owner', type: 'notify', label: 'Alert Owner', config: { recipients: '{record.owner}', title: '🔴 Critical: {record.name}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], edges: [], }, { name: 'Exec', - nodes: [{ id: 'alert_exec', type: 'script', label: 'Alert Exec', config: { actionType: 'email', inputs: { to: 'exec@example.com', subject: '🔴 Critical project: {record.name}' } } }], + nodes: [{ id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { recipients: 'exec@example.com', title: '🔴 Critical project: {record.name}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], edges: [], }, ], diff --git a/examples/app-showcase/src/docs/showcase_tour_ui.md b/examples/app-showcase/src/docs/showcase_tour_ui.md index 64bd411a54..38012ee3ab 100644 --- a/examples/app-showcase/src/docs/showcase_tour_ui.md +++ b/examples/app-showcase/src/docs/showcase_tour_ui.md @@ -58,11 +58,15 @@ canonical example of each linked from that page. - `src/ui/actions/` — the ActionType × location matrix (script / url / modal / flow / api / form), visible as buttons across Task screens. - Actions over a SELECTION come in two flavours, one view each: Task's - **Bulk Actions** names declared actions in `bulkActions`, and each selected - record is fanned out through the action runner (a script and a custom - endpoint, neither of them a field patch); Project's `bulkActionDefs` instead - mass-EDITS through the data API. `action.bulkEnabled` is not a third way — - it was retired in spec 17 and its tombstone points at `bulkActions`. + **Bulk Actions** runs declared actions through the action runner — named in + `bulkActions` for the default per-record fan-out (a script and a custom + endpoint, neither of them a field patch), or declared as a + `bulkActionDefs` entry with `execution: 'aggregate'` for ONE dispatch + carrying every selected id as `params._selectedIds` (Recalculate + Selection, objectui#3139 — the single-zip / merged-PDF shape); Project's + `bulkActionDefs` instead mass-EDITS through the data API. + `action.bulkEnabled` is not a third way — it was retired in spec 17 and + its tombstone points at `bulkActions`. ## Themes diff --git a/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts b/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts index a2c3d95ab8..71a8625fbb 100644 --- a/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts +++ b/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts @@ -52,7 +52,7 @@ export const ShowcaseExternalDatasource = defineDatasource({ // label: 'Analytics Warehouse (Postgres)', // driver: 'postgres', // schemaMode: 'external', -// config: { host: 'localhost', port: 5432, database: 'analytics', user: 'readonly' }, +// config: { host: 'localhost', port: 5432, database: 'analytics', username: 'readonly' }, // external: { // allowWrites: false, // credentialsRef: 'secret:warehouse/password', diff --git a/examples/app-showcase/src/system/server/recalc-endpoint.ts b/examples/app-showcase/src/system/server/recalc-endpoint.ts index e5a8612ada..23ea0d20ee 100644 --- a/examples/app-showcase/src/system/server/recalc-endpoint.ts +++ b/examples/app-showcase/src/system/server/recalc-endpoint.ts @@ -18,6 +18,13 @@ * string-target api action, so the body carries `id` + the record fields. * We recompute `estimate_hours` from the schedule window (working at 8h/day) * and persist it; `refreshAfter: true` on the action repaints the new value. + * + * Aggregate branch (objectui#3139): an `execution: 'aggregate'` bulk dispatch + * (see `showcase_recalc_selection` + task.view's `bulk_actions`) POSTs ONE + * request whose body carries `_selectedIds: string[]` instead of a single id. + * The handler recomputes every id in that one call and reports the count — + * all-or-nothing, per the aggregate contract: a failure rejects the whole + * request rather than reporting partial success. */ interface RecalcHostContext { @@ -58,6 +65,30 @@ export function registerRecalcEndpoint(ctx: RecalcHostContext): void { }; try { const body = ((req as { body?: Record })?.body) ?? {}; + // Aggregate bulk dispatch (objectui#3139): one request, every selected + // id in `_selectedIds`. The single-record shape has the record fields + // in the body; here only ids arrive, so recompute from a flat 8h + // baseline per task — the point of the specimen is the ONE-call shape, + // not the estimation model. + const selectedIds = Array.isArray(body._selectedIds) + ? (body._selectedIds as unknown[]).map(String).filter(Boolean) + : undefined; + if (selectedIds) { + if (selectedIds.length === 0) { + r.status(400); + r.json({ success: false, error: '_selectedIds is empty' }); + return; + } + for (const sid of selectedIds) { + await ctx.ql.update( + 'showcase_task', + { id: sid, estimate_hours: 8 }, + { where: { id: sid } }, + ); + } + r.json({ success: true, data: { recalculated: selectedIds.length } }); + return; + } const id = (body.id ?? body.recordId) as string | undefined; if (!id) { r.status(400); diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b27965f17c..9e833e7cd7 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -233,6 +233,18 @@ export const ShowcaseTranslationBundle = { legacy_row_actions: { label: '旧式行操作' }, bulk_actions: { label: '批量操作' }, }, + _actions: { + // The two recalc surfaces of the same endpoint: one dispatch per + // record vs ONE dispatch for the whole selection (objectui#3139). + showcase_recalc_estimate: { + label: '重算工时', + successMessage: '工时已重算。', + }, + showcase_recalc_selection: { + label: '重算所选', + successMessage: '已为整个选中集重算工时。', + }, + }, }, showcase_account: { label: '客户', diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 8d16dcb6fb..622e58dd72 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -117,6 +117,39 @@ export const RecalcEstimateAction = defineAction({ refreshAfter: true, }); +/** + * api, AGGREGATE-dispatched — the `execution: 'aggregate'` specimen + * (objectui#3139). The action itself is an ordinary api action; what makes it + * aggregate is the VIEW's `bulkActionDefs` entry naming it with + * `execution: 'aggregate'` (see `task.view.ts` → `bulk_actions`). The + * renderer then dispatches it ONCE for the whole selection, with every + * selected id in `params._selectedIds` — the recalc endpoint's batch branch + * recomputes all of them in that single call (the "one zip for N devices" + * shape, minus the zip). Contrast with RecalcEstimateAction above: same + * endpoint, one POST per record. + * + * `locations` still has to be declared, even though the selection bar entry + * comes from the view. Omitting it does NOT mean "nowhere": the action:bar + * renderer treats a missing/empty `locations` as "every location" + * (objectui `action-bar.tsx`), so a locations-less action also lands on the + * LIST TOOLBAR — where there is no selection, so the dispatch posts no + * `_selectedIds` and the endpoint rejects it. Declaring `record_more` keeps + * the single-record entry somewhere it works (the endpoint's per-record + * branch, via `recordIdParam`) and off the toolbar. See objectui#3142. + */ +export const RecalcSelectionAction = defineAction({ + name: 'showcase_recalc_selection', + label: 'Recalculate Selection', + icon: 'calculator', + objectName: task, + type: 'api', + target: '/api/v1/showcase/recalc', + successMessage: 'Estimates recalculated for the whole selection.', + locations: ['record_more'], + recordIdParam: 'recordId', + refreshAfter: true, +}); + /** form — open a parameter form dialog. */ export const LogTimeAction = defineAction({ name: 'showcase_log_time', @@ -335,6 +368,7 @@ export const allActions = [ BulkReassignAction, QuickViewAction, RecalcEstimateAction, + RecalcSelectionAction, LogTimeAction, NewTaskAction, SubmitForSignoffAction, diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index aec955a207..38a20f3d14 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -6,16 +6,16 @@ import { definePage } from '@objectstack/spec/ui'; * Renewals Pipeline — a `kind:'react'` business scenario (ADR-0081). * * A renewals manager works a list of accounts by lifecycle stage; selecting one - * drives a 360° panel (highlights + invoices + a value-by-status chart) and a - * pre-styled `` to update the account in place. + * drives a 360° panel (account summary + invoices + a value-by-status chart) and + * a pre-styled `` to update the account in place. * Every block prop is taken straight from the react-tier contract * (skills/objectstack-ui/references/react-blocks.md). * * The 360 panel deliberately shows BOTH rollup styles side by side: * • hand-rolled — a `useAdapter()` effect counts related projects/invoices * into a KPI strip (full control, you own loading/refresh), vs - * • framework blocks — ``/`` do the same - * cross-object reads declaratively (zero data code). + * • framework blocks — ``/`` do the same cross-object + * reads declaratively (zero data code). * (This comparison absorbed the former Account Cockpit page.) * * The chart is written in the spec `ChartConfig` shape (#3729) and its axes are @@ -24,14 +24,20 @@ import { definePage } from '@objectstack/spec/ui'; * `groupBy`) and `total` (its `field`) — not by a dataset-style measure name. * `os validate` checks both halves. * - * `` binds the CHILD object it lists (`showcase_invoice`), - * not the parent — the parent is `recordId`, and `relationshipField="account"` - * is the invoice's lookup back to it. This page used to pass the parent, which - * is what #4340 found: the react contract had glossed `objectName` as "the - * parent object" while the schema (and the renderer behind both surfaces) read - * it as the related one, so the list resolved `total` against an account and - * came back empty. Every field-bearing prop on the page is now checked against - * the object it actually names. + * The selected account is bound BY REACT STATE, not by a record context: `sel` + * is the parent id, so the invoice list is an ordinary `` filtered on + * the child's lookup (`['account', '=', sel]`) and the summary is an + * ``. Both read their binding from their own props, + * which is what makes them work on this tier. + * + * This panel used to be `` + ``, and both + * rendered EMPTY here (#4413): every `record:*` block takes its record from the + * context a record page mounts, and a react page mounts none — the + * `objectName`/`recordId` the contract published for them were read by no + * renderer. They are out of the react tier now, and `os validate` rejects them + * on this surface rather than letting the next author rediscover it at runtime. + * (#4340's finding still holds where it applies: on a RECORD page + * `` is the CHILD object, never the parent.) * * Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`; * data blocks and the drawer bring their own compiled styling. The drawer sets @@ -121,7 +127,7 @@ function Page() { - +
@@ -131,7 +137,8 @@ function Page() { - +

Invoices

+ {editing ? ( > = { + validate: 'validate.ts', + build: 'compile.ts', + lint: 'lint.ts', +}; + +const sourceOf = (file: string) => readFileSync(join(commandsDir, file), 'utf8'); + +/** + * Rules a command file may still call directly, each with the reason it is not + * a registry entry. + * + * This is the ratchet — the `FLOW_WRITE_NODE_TYPES_DEFERRED` / `TEST_DEBT` + * discipline applied to rule wiring. Adding a key here is a deliberate claim + * that the rule is NOT one of the three commands' shared author-time checks; + * anything else belongs in `AUTHORING_RULES`, where all three commands get it + * at once. A rule that merely reads the stack does not qualify, no matter how + * convenient the local call site is. + */ +const DIRECT_CALL_RATCHET: Readonly> = { + lintConfig: + "`os lint`'s own entry point, defined in lint.ts itself — the rubric (naming, labels, structure), " + + 'not a shared authoring rule. Its `error` severity is a lint verdict, not a publish gate.', + lintDataModel: + "`os lint`'s data-model best-practice sweep (ADR-0035 conventions + the eval rubric in score.ts). " + + 'Deliberately lint-only: `os build` has never rejected a lookup that should have been a ' + + 'master_detail, and making it do so is a product decision, not a wiring fix.', + lintUnknownStackKeys: + 'Needs `ObjectStackDefinitionSchema` to diff the authored keys against what the schema declares, ' + + 'and must read the PRE-parse stack, which only the two commands that parse actually have. ' + + '`os lint` never parses, so it has nothing to diff against.', + lintUnknownAuthoringKeys: + 'The object/field half of the same pre-parse key diff (#3786) — wired with, and for the same ' + + 'reason as, `lintUnknownStackKeys`.', +}; + +/** + * Symbols a command file may import from `@objectstack/lint` without being a + * registry entry. Same ratchet discipline, on the import rather than the call — + * `buildAccessMatrix`/`diffAccessMatrix` do not match the `lint*`/`validate*` + * naming convention the call-site scan keys on, so the import scan is what + * covers them. + */ +const LINT_IMPORT_RATCHET: Readonly> = { + buildAccessMatrix: + '[ADR-0090 D6] Derives the (permission set × object) capability matrix. Not a rule — it produces ' + + 'the snapshot `os build` diffs against a committed file, so it is an artifact step, not a check.', + diffAccessMatrix: + 'The other half of the D6 snapshot gate: it compares a committed `access-matrix.json` against the ' + + 'matrix above. Reads a file next to the config, so it cannot run where that file may not exist.', +}; + +/** Every registry rule name, plus the member names of the suite it embeds. */ +const REGISTRY_NAMES = new Set([ + ...AUTHORING_RULES.map((r) => r.name), + ...REFERENCE_INTEGRITY_RULES.map((r) => r.name), +]); + +/** + * Rules `@objectstack/lint` EXPORTS but that no authoring command runs, each + * with the reason it is legitimately unwired (#4449). + * + * Empty, and that is the healthy state. An entry here is a written claim that + * the rule has a consumer OTHER than the three commands (a Studio panel, an MCP + * authoring surface) — not a parking space for one nobody got round to wiring. + * Under ADR-0049 enforce-or-remove, a rule with no consumer at all is deleted, + * not ledgered. + */ +const UNWIRED_RULE_LEDGER: Readonly> = {}; + +/** + * Every `validate*` / `lint*` symbol the lint package's public barrel exports — + * read from source for the same reason the call-site scans are: vitest inlines + * imports, so the module namespace object cannot tell an exported RULE from an + * exported helper the way the naming convention can. + */ +function exportedLintRules(): string[] { + const source = readFileSync(join(repoRoot, 'packages/lint/src/index.ts'), 'utf8'); + const names = new Set(); + for (const m of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) { + for (const raw of m[1].split(',')) { + const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()?.trim(); + if (name && /^(?:validate|lint)[A-Z]/.test(name)) names.add(name); + } + } + return [...names].sort(); +} + +/** Every `lintFoo(`/`validateFoo(` call site in a source file. */ +function ruleCallsIn(source: string): string[] { + return [...new Set(source.match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [])]; +} + +/** Every symbol imported from `@objectstack/lint` by a source file. */ +function lintImportsIn(source: string): string[] { + const names: string[] = []; + const importRe = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]@objectstack\/lint['"]/g; + for (const m of source.matchAll(importRe)) { + for (const raw of m[1].split(',')) { + const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim(); + if (name) names.push(name); + } + } + return [...new Set(names)]; +} + +/** + * The body of `export function `, up to the next top-level `export` (or + * end of file) — so a module hosting several rules is read one rule at a time. + * + * Known limitation, stated rather than hidden: a finding emitted from a helper + * declared ABOVE the export is outside the slice. The check is a ratchet on the + * common shape (a rule emits its own findings), not a proof. + */ +function ruleBody(source: string, name: string): string | null { + const start = source.indexOf(`export function ${name}`); + if (start < 0) return null; + const rest = source.slice(start + 1); + const end = rest.indexOf('\nexport '); + return end < 0 ? rest : rest.slice(0, end); +} + +/** Does this rule body emit `severity: 'error'`? (Union type declarations do not count.) */ +function emitsError(body: string): boolean { + const withoutTypeDecls = body + .split('\n') + .filter((line) => !/severity\??:\s*(?:'[a-z]+'\s*\|\s*)+'[a-z]+'/.test(line)) + .join('\n'); + return /severity:[^;\n]*'error'/.test(withoutTypeDecls); +} + +describe('authoring-rule registry wiring (#4409)', () => { + it.each([...AUTHORING_COMMANDS])('os %s runs the registry and nothing by hand', (command) => { + const source = sourceOf(COMMAND_FILES[command]); + expect(source, `${COMMAND_FILES[command]} must run the registry`).toMatch(/\brunAuthoringRules\s*\(/); + + const handWired = ruleCallsIn(source).filter((name) => REGISTRY_NAMES.has(name)); + expect( + handWired, + `${COMMAND_FILES[command]} calls registry rule(s) directly: ${handWired.join(', ')}.\n` + + `Run them through runAuthoringRules() instead — a per-rule call site is exactly how a rule ` + + `ends up on two commands out of three (#3782, #4394, #4409).`, + ).toEqual([]); + }); + + it.each([...AUTHORING_COMMANDS])('os %s has no unratcheted direct rule call', (command) => { + const source = sourceOf(COMMAND_FILES[command]); + const unratcheted = ruleCallsIn(source) + .filter((name) => !REGISTRY_NAMES.has(name)) + .filter((name) => !(name in DIRECT_CALL_RATCHET)) + .sort(); + + expect( + unratcheted, + `${COMMAND_FILES[command]} hand-wires ${unratcheted.length} rule(s) the registry does not know ` + + `about: ${unratcheted.join(', ')}.\n` + + `Add each to AUTHORING_RULES in packages/cli/src/lint/authoring-rules.ts so all three commands ` + + `run it — or, if it genuinely is not a shared author-time rule (it needs the filesystem, the ` + + `emitted artifact, or it belongs to os lint's own style rubric), add it to DIRECT_CALL_RATCHET ` + + `in this file WITH the reason. Silence is the one option that is not available.`, + ).toEqual([]); + }); + + it.each([...AUTHORING_COMMANDS])('os %s imports no unratcheted symbol from @objectstack/lint', (command) => { + const source = sourceOf(COMMAND_FILES[command]); + const unratcheted = lintImportsIn(source) + .filter((name) => !(name in LINT_IMPORT_RATCHET)) + .sort(); + + expect( + unratcheted, + `${COMMAND_FILES[command]} imports ${unratcheted.join(', ')} from @objectstack/lint directly. ` + + `Register the rule in AUTHORING_RULES, or add the symbol to LINT_IMPORT_RATCHET with a reason.`, + ).toEqual([]); + }); + + // ── The invariant the issue exists for ─────────────────────────────── + + /** + * Gating rules that do NOT yet run on all three commands, each with the reason + * and the plan to close it. + * + * Empty, and that is the healthy state — a stack must not be publishable + * through a command that skips a gate another command enforces. An entry here + * is a KNOWN hole: `os build` may ship what `os lint` refuses, or the reverse. + */ + const GATING_COVERAGE_DEBT: Readonly> = {}; + + it('every gating rule runs on all three commands', () => { + const holes = AUTHORING_RULES.filter((r) => r.tier === 'gating') + .filter((r) => r.commands.length !== AUTHORING_COMMANDS.length) + .filter((r) => !(r.name in GATING_COVERAGE_DEBT)) + .map((r) => `${r.name} (runs on: ${r.commands.join(', ')})`); + + expect( + holes, + `${holes.length} rule(s) can emit \`error\` but do not run on all three authoring commands: ` + + `${holes.join('; ')}.\n` + + `A gate is only as strong as the weakest command an author or CI happens to run, so partial ` + + `coverage is not a stricter check — it is a coin flip. Widen \`commands\` to all three.`, + ).toEqual([]); + }); + + it('the three commands run the identical gating set', () => { + const gatingFor = (command: AuthoringCommand) => + authoringRulesFor(command) + .filter((r) => r.tier === 'gating') + .map((r) => r.name) + .sort(); + + const [validate, build, lint] = AUTHORING_COMMANDS.map(gatingFor); + expect(build, 'os build must gate on exactly what os validate gates on').toEqual(validate); + expect(lint, 'os lint must gate on exactly what os validate gates on').toEqual(validate); + }); + + it('every narrowed rule carries a reason', () => { + const unexplained = AUTHORING_RULES.filter((r) => r.commands.length !== AUTHORING_COMMANDS.length) + .filter((r) => (r.scopeReason ?? '').trim().length < 40) + .map((r) => r.name); + + expect( + unexplained, + `${unexplained.join(', ')} run(s) on fewer than three commands with no substantive scopeReason. ` + + `A narrowing must be a written decision, not an omission — that distinction IS the fix (#4409).`, + ).toEqual([]); + }); + + it('every rule declares a source file that exists', () => { + const missing = AUTHORING_RULES.filter((r) => !existsSync(join(repoRoot, r.source))).map( + (r) => `${r.name} → ${r.source}`, + ); + expect(missing, `stale source path(s): ${missing.join(', ')}`).toEqual([]); + }); + + it('every advisory rule really is advisory', () => { + // The check that keeps the tier honest: without it, mislabelling a gate as + // advisory would silently buy it the right to partial coverage. + const liars = AUTHORING_RULES.filter((r) => r.tier === 'advisory') + .map((r) => { + const body = ruleBody(readFileSync(join(repoRoot, r.source), 'utf8'), r.name); + if (body === null) return `${r.name} (no \`export function ${r.name}\` in ${r.source})`; + return emitsError(body) ? `${r.name} (${r.source} emits severity: 'error')` : null; + }) + .filter((x): x is string => x !== null); + + expect( + liars, + `${liars.join('; ')}.\n` + + `A rule that can emit \`error\` is \`gating\` and must run on all three commands. Change its ` + + `tier and widen \`commands\` — do not leave a gate wearing an advisory label.`, + ).toEqual([]); + }); + + // ── The other direction: a rule wired NOWHERE (#4449) ──────────────── + + /** + * The invariants above all start FROM a registry and look at the commands. + * That view is blind by construction to a rule that never entered a registry: + * `validateFormLayout` was implemented, unit-tested, exported and given four + * published rule ids, and ran on zero stacks for as long as it existed. The + * closure is the reverse subtraction — exported rules MINUS both registries — + * which is the shape #4402 (a name list guards only the names on it) and + * #4409 (a registry guards only what entered it) each missed one layer down. + */ + it('every rule @objectstack/lint exports is wired into a registry', () => { + const unwired = exportedLintRules() + .filter((name) => !REGISTRY_NAMES.has(name)) + .filter((name) => !(name in UNWIRED_RULE_LEDGER)); + + expect( + unwired, + `@objectstack/lint exports ${unwired.length} rule(s) that no authoring command runs: ` + + `${unwired.join(', ')}.\n` + + `A rule on the public export surface reads — to a human and to an AI author alike — as a ` + + `check the platform performs. Either register it in AUTHORING_RULES ` + + `(packages/cli/src/lint/authoring-rules.ts) so all three commands run it, or add it to ` + + `UNWIRED_RULE_LEDGER in this file WITH the real consumer that justifies it — or delete it ` + + `under ADR-0049 enforce-or-remove. Advertising it while running it nowhere is the one option ` + + `that is not available (Prime Directive #10).`, + ).toEqual([]); + }); + + it('the form-layout rule really runs, and really finds something', () => { + // The wiring assertion above proves membership. This proves the entry is + // live end to end: the rule reaches all three commands AND its `run` + // adapter returns the finding a broken stack earns. Both halves matter — + // #4449 is precisely a rule that existed, passed its own unit tests, and + // produced no output on any real stack. + for (const command of AUTHORING_COMMANDS) { + expect( + authoringRulesFor(command).map((r) => r.name), + `os ${command} must run validateFormLayout`, + ).toContain('validateFormLayout'); + } + + const entry = AUTHORING_RULES.find((r) => r.name === 'validateFormLayout')!; + const findings = entry.run( + { + objects: [{ name: 'widget', fields: { title: { type: 'text' } } }], + views: [ + { + name: 'widget_form', + data: { object: 'widget' }, + sections: [{ fields: [{ field: 'no_such_field', colSpan: 2 }] }], + }, + ], + }, + {}, + ); + expect(findings.map((f) => f.rule).sort()).toEqual([ + 'absolute-colspan-discouraged', + 'form-field-unknown', + ]); + }); + + it('every ledger entry is still an exported rule', () => { + // Same anti-rot discipline as the two ratchets: an entry naming a rule that + // no longer exists silently widens the allowance for the next one. + const exported = new Set(exportedLintRules()); + const stale = Object.keys(UNWIRED_RULE_LEDGER).filter((n) => !exported.has(n)); + expect(stale, `UNWIRED_RULE_LEDGER entries no longer exported: ${stale.join(', ')}`).toEqual([]); + }); + + // ── Guards the guard ───────────────────────────────────────────────── + + it('the registry is non-empty and still holds the rules that motivated it', () => { + expect(AUTHORING_RULES.length).toBeGreaterThan(20); + const names = AUTHORING_RULES.map((r) => r.name); + // The three that `os build` was blind to — it published what the other + // commands refuse. `validateApprovalApprovers` is #4409's worked example. + expect(names).toContain('validateApprovalApprovers'); + expect(names).toContain('validateListViewMode'); + expect(names).toContain('validateViewContainers'); + // The gating pair `os lint` was blind to, so the cheap pre-flight passed + // stacks the build rejects. + expect(names).toContain('lintAutonumberFormats'); + expect(names).toContain('lintViewRefs'); + // The suite #3583/#4402 built, now one entry among the rest. + expect(names).toContain('validateReferenceIntegrity'); + expect(REFERENCE_INTEGRITY_RULES.length).toBeGreaterThan(0); + // The rule whose absence from `os lint` motivated the suite's own guard. + expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toContain('validateReadonlyFlowWrites'); + }); + + it('the source scans still match something (non-vacuous)', () => { + // If the extraction regexes silently stop matching, every set-difference + // above passes while checking nothing. + expect(ruleCallsIn(sourceOf('lint.ts')).length).toBeGreaterThan(0); + expect(lintImportsIn(sourceOf('compile.ts')).length).toBeGreaterThan(0); + expect(emitsError("severity: 'error',")).toBe(true); + expect(emitsError("severity: 'error' | 'warning';")).toBe(false); + expect(emitsError("if (f.severity === 'error') return;")).toBe(false); + // The export scan feeding the unwired-rule closure: if it stops matching, + // that set difference is empty for the wrong reason. + const exported = exportedLintRules(); + expect(exported.length).toBeGreaterThan(20); + expect(exported).toContain('validateReferenceIntegrity'); + expect(exported).toContain('validateFormLayout'); + }); + + it('every ratchet entry is still load-bearing', () => { + // A ratchet nobody prunes rots into a permission slip. Each entry must + // correspond to a call/import that actually exists somewhere. + const allCalls = new Set(Object.values(COMMAND_FILES).flatMap((f) => ruleCallsIn(sourceOf(f)))); + const staleCalls = Object.keys(DIRECT_CALL_RATCHET).filter((n) => !allCalls.has(n)); + expect(staleCalls, `DIRECT_CALL_RATCHET entries with no call site left: ${staleCalls.join(', ')}`).toEqual([]); + + const allImports = new Set(Object.values(COMMAND_FILES).flatMap((f) => lintImportsIn(sourceOf(f)))); + const staleImports = Object.keys(LINT_IMPORT_RATCHET).filter((n) => !allImports.has(n)); + expect(staleImports, `LINT_IMPORT_RATCHET entries with no import left: ${staleImports.join(', ')}`).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index e6a7680dd2..db6686b774 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -15,19 +15,9 @@ import { } from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; import { lowerCallables } from '../utils/lower-callables.js'; -import { validateStackExpressions } from '@objectstack/lint'; -import { validateVisibilityPredicates } from '@objectstack/lint'; -import { validateWidgetBindings } from '@objectstack/lint'; -import { validateDashboardActionRefs } from '@objectstack/lint'; -import { validateFilterTokens } from '@objectstack/lint'; -import { validateReferenceIntegrity } from '@objectstack/lint'; -import { validateResponsiveStyles } from '@objectstack/lint'; -import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; -import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; -import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; -import { lintUniqueDeclarations } from '../lint/data-model-rules.js'; -import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; -import { lintViewRefs } from '../utils/lint-view-refs.js'; +import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; +import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '../lint/authoring-rules.js'; +import { resolveSduiManifest } from '../utils/sdui-manifest.js'; import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js'; @@ -173,46 +163,62 @@ export default class Compile extends Command { this.exit(1); } - // 3b. Validate expressions against the resolved schema (ADR-0032 §1a/1b). - // The whole normalized stack is in hand here, so flow/validation - // predicates are checked for CEL syntax AND that `record.` - // references exist on the target object — failing the build with a - // located, corrective message instead of a silent runtime `false`. - if (!flags.json) printStep('Validating expressions (ADR-0032)...'); - const exprIssues = validateStackExpressions(result.data as Record); - const exprErrors = exprIssues.filter((i) => i.severity !== 'warning'); - const exprWarnings = exprIssues.filter((i) => i.severity === 'warning'); - if (exprErrors.length > 0) { + // 3b. The author-time rule registry (#4409) — one table, three commands. + // `os build` was the WEAKEST of the three authoring gates before it: + // it published stacks `os validate` or `os lint` refuse, because the + // rules each command ran were whatever its author remembered to wire. + // `validateApprovalApprovers` was the worked example — a flow whose + // expression approver does not parse built and published green while + // `os lint` rejected it. The build is the command that SHIPS, so + // "weakest gate" here means broken metadata reaching an environment. + // + // Which rules run, on which stack tier, and why any of them is scoped + // is declared in `lint/authoring-rules.ts`. Do not add a call site here. + const registered = authoringRulesFor('build'); + if (!flags.json) printStep(`Running author-time rules (${registered.length})...`); + const findings = runAuthoringRules('build', { + normalized: normalized as Record, + parsed: result.data as Record, + sduiManifest: resolveSduiManifest(), + }); + const { errors: ruleErrors, advisories: ruleAdvisories } = splitBySeverity(findings); + + if (ruleAdvisories.length > 0 && !flags.json) { + console.log(''); + for (const f of ruleAdvisories.slice(0, 50)) { + printWarning(`${f.where}: ${f.message}`); + if (f.hint) console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + } + if (ruleErrors.length > 0) { + // Every failing rule reports at once — see the note in `validate.ts`. if (flags.json) { - await emitJson({ success: false, error: 'expression validation failed', issues: exprErrors, warnings: exprWarnings }, 0, { compact: true }); + await emitJson( + { success: false, error: 'author-time rules failed', issues: ruleErrors, warnings: ruleAdvisories }, + 0, + { compact: true }, + ); this.exit(1); } console.log(''); - printError(`Expression validation failed (${exprErrors.length} issue${exprErrors.length > 1 ? 's' : ''})`); - for (const i of exprErrors.slice(0, 50)) { - console.log(` • ${i.where}: ${i.message}`); - console.log(` source: \`${i.source}\``); + printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`); + for (const f of ruleErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); } this.exit(1); } - // Advisory expression warnings (#1928 tier 3) — surfaced, never fatal. - if (exprWarnings.length > 0 && !flags.json) { - printWarning(`Expression warnings (${exprWarnings.length})`); - for (const i of exprWarnings.slice(0, 50)) { - console.log(` • ${i.where}: ${i.message}`); - console.log(` source: \`${i.source}\``); - } - } - // 3b-ter. [#3366] Installable-provider preflight. Every capability the app + // 3c. [#3366] Installable-provider preflight. Every capability the app // DECLARES in `requires: [...]` must have a provider resolvable in the - // active edition. `os validate` only checks the token vocabulary and - // `os build` never resolved providers, so a `requires` entry whose - // provider has NO installable version in this edition (e.g. `ai` → - // @objectstack/service-ai, cloud-only since ADR-0025) slipped through - // to a generic `os start` crash. Fail the build with the edition-aware - // message instead; an absent-but-installable provider is a `pnpm add` - // hint (advisory), and a satisfied list passes silently. + // active edition. A `requires` entry whose provider has NO installable + // version in this edition (e.g. `ai` → @objectstack/service-ai, + // cloud-only since ADR-0025) otherwise slips through to a generic + // `os start` crash. Absent-but-installable is a `pnpm add` hint. + // + // Not a registry rule: it reads `node_modules`, not the stack. if (!flags.json) printStep('Checking capability providers (#3366)...'); const capPreflight = preflightRequiredCapabilities({ requires: Array.isArray((config as { requires?: unknown[] }).requires) @@ -243,25 +249,11 @@ export default class Compile extends Command { } } - // 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered - // binding root. Checked on `normalized` (PRE-parse): the schema folds - // `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data` - // no longer carries the alias the author wrote. Advisory, never fatal. - const visibilityFindings = validateVisibilityPredicates(normalized as Record); - if (visibilityFindings.length > 0 && !flags.json) { - printWarning(`Visibility warnings (${visibilityFindings.length}) — ADR-0089`); - for (const f of visibilityFindings.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(` ${f.hint}`); - console.log(` rule: ${f.rule} at ${f.path}`); - } - } - - // 3b-ter. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and - // so drop silently on the way to storage. PRE-parse for the same - // reason as the rule above. `defineStack` already warns for configs - // authored through it; this covers the ones that skip it (a plain - // object default-export, `strict: false`) and would otherwise emit an + // 3d. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so + // drop silently on the way to storage. PRE-parse, since the parse is + // what strips them. `defineStack` already warns for configs authored + // through it; this covers the ones that skip it (a plain object + // default-export, `strict: false`) and would otherwise emit an // artifact with the key quietly gone. Advisory, never fatal. const unknownKeyFindings = [ ...lintUnknownStackKeys(normalized as Record, ObjectStackDefinitionSchema), @@ -274,356 +266,16 @@ export default class Compile extends Command { } } - // 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks - // that need the widget's `dataset` reference resolved to its dataset - // and `dimensions`/`values` resolved to declared names. Errors are - // unresolvable bindings (dangling dataset/dimension/measure or a - // chartConfig field the query result won't contain) and fail the - // build; warnings are advisory and suppressible per widget via - // `suppressWarnings: ['']`. - if (!flags.json) printStep('Checking dashboard widget bindings (ADR-0021)...'); - const widgetFindings = validateWidgetBindings(result.data as Record); - const widgetErrors = widgetFindings.filter((f) => f.severity === 'error'); - const widgetWarnings = widgetFindings.filter((f) => f.severity === 'warning'); - if (widgetErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'widget binding validation failed', issues: widgetErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Dashboard widget integrity failed (${widgetErrors.length} issue${widgetErrors.length > 1 ? 's' : ''})`); - for (const f of widgetErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (widgetWarnings.length > 0 && !flags.json) { - console.log(''); - for (const w of widgetWarnings) { - printWarning(`${w.where}: ${w.message}`); - console.log(chalk.dim(` ${w.hint}`)); - console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`)); - } - } - - // 3c-bis. Dashboard action/route reference integrity (ADR-0049 for - // references, #3367). A header/widget action naming a `script`/`modal` - // target that resolves to no defined action, or a `url` target that - // matches no in-app route, ships a button that renders and silently - // does nothing on click. Dead script/modal targets fail the build - // (they fail open at runtime); unresolved url routes are advisory. - if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...'); - const actionRefFindings = validateDashboardActionRefs(result.data as Record); - const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error'); - const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); - if (actionRefErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`); - for (const f of actionRefErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (actionRefWarnings.length > 0 && !flags.json) { - console.log(''); - for (const w of actionRefWarnings) { - printWarning(`${w.where}: ${w.message}`); - console.log(chalk.dim(` ${w.hint}`)); - console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`)); - } - } - - // 3a-ter. Filter placeholder resolvability (#3574). A filter value that - // resolves in neither vocabulary — `{current_user}` instead of - // `{current_user_id}` — reaches the data engine as a literal and - // matches nothing, so the surface renders empty with no error. That - // silent zero is indistinguishable from a genuine zero at review - // time, and an AI author reads it as a successful query. Fails the - // build because authoring time is the last point the author sees it. - if (!flags.json) printStep('Checking filter placeholders (#3574)...'); - const filterTokenFindings = validateFilterTokens(result.data as Record); - const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error'); - if (filterTokenErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'filter placeholder validation failed', issues: filterTokenErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`); - for (const f of filterTokenErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3b-bis. Object & action name references (#3583) — the reference sites - // `defineStack` does not cover: action-param `reference` / - // `objectOverride`, dashboard filter `optionsFrom.object`, nav - // `requiresObject` gates, and the name-bound action surfaces - // (`bulkActions`/`rowActions`, page quick-actions, nav action items). - // Plus page-component field bindings and the chart surfaces outside - // dashboards (report charts, list-view charts, dataset-bound page - // chart components) — same ADR-0021 semantic layer, where an axis - // naming a raw field instead of a measure renders an empty series. - // All plain strings in the schema, so a name resolving to nothing - // ships and fails silently. Errors fail the build; the - // platform-prefixed-but-unregistered case is advisory (a third-party - // package may still provide it). Translation bundles are checked in - // the reverse direction (keys naming metadata that does not exist, - // option keys written as the display label) — advisory throughout, - // since an orphan key is inert rather than broken. - if (!flags.json) printStep('Checking object & action references (#3583)...'); - const refFindings = validateReferenceIntegrity(result.data as Record); - const refErrors = refFindings.filter((f) => f.severity === 'error'); - const refWarnings = refFindings.filter((f) => f.severity === 'warning'); - if (refErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'reference integrity validation failed', issues: refErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Reference integrity check failed (${refErrors.length} issue${refErrors.length > 1 ? 's' : ''})`); - for (const f of refErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (!flags.json) { - for (const w of refWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without - // an `id` drops its CSS silently; Tailwind-in-className does nothing - // from metadata. Same bar for hand-authored and AI-generated pages - // (ADR-0019). Errors fail the build; warnings are advisory. - if (!flags.json) printStep('Checking SDUI styling (ADR-0065)...'); - const styleFindings = validateResponsiveStyles(result.data as Record); - const styleErrors = styleFindings.filter((f) => f.severity === 'error'); - const styleWarnings = styleFindings.filter((f) => f.severity === 'warning'); - if (styleErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'SDUI styling validation failed', issues: styleErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`SDUI styling check failed (${styleErrors.length} issue${styleErrors.length > 1 ? 's' : ''})`); - for (const f of styleErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (styleWarnings.length > 0 && !flags.json) { - console.log(''); - for (const w of styleWarnings) { - printWarning(`${w.where}: ${w.message}`); - console.log(chalk.dim(` ${w.hint}`)); - console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`)); - } - } - - // 3d. Flow authoring anti-pattern lint (#1874) — for valid-but-fragile flow - // metadata (e.g. a record-change trigger using a date-EQUALITY time - // condition that only fires on the exact day). Guides the author — very - // often an AI generating templates — toward the robust pattern. - // - // Findings are advisory by DEFAULT, but a finding marked - // `severity: 'error'` FAILS the build (#3760). Before that, this gate - // read as a gate and behaved as a comment: `flow-runas-unscoped` flags - // metadata the runtime now REFUSES to execute, and for the audience the - // rule exists to protect — very often an AI generating flows in bulk — - // an advisory line is close to no net at all. - const flowLint = lintFlowPatterns(result.data as Record); - const flowLintErrors = flowLint.filter((f) => f.severity === 'error'); - const flowLintWarnings = flowLint.filter((f) => f.severity !== 'error'); - if (flowLintWarnings.length > 0 && !flags.json) { - console.log(''); - for (const fnd of flowLintWarnings) { - printWarning(`${fnd.where}: ${fnd.message}`); - console.log(chalk.dim(` ${fnd.hint}`)); - console.log(chalk.dim(` rule: ${fnd.rule}`)); - } - } - if (flowLintErrors.length > 0) { - if (flags.json) { - this.log(JSON.stringify({ success: false, flowLintErrors }, null, 2)); - this.exit(1); - } - console.log(''); - printError(`Flow authoring check failed (${flowLintErrors.length} error${flowLintErrors.length > 1 ? 's' : ''})`); - for (const fnd of flowLintErrors) { - console.log(` • ${fnd.where}: ${fnd.message}`); - console.log(chalk.dim(` ${fnd.hint}`)); - console.log(chalk.dim(` rule: ${fnd.rule}`)); - } - this.exit(1); - } - - // 3d-bis. Liveness author-warning lint — close the spec-liveness loop on - // the author side: an authored property the ledger marks dead-and- - // misleading (e.g. `object.enable.files`, `field.columnName`) or - // experimental is set hopefully but does nothing / isn't enforced at - // runtime. Advisory only; ledger-driven (entries opt in via - // `authorWarn`), so it's high-signal and NEVER fails the build. - const livenessLint = lintLivenessProperties(result.data as Record); - if (livenessLint.length > 0 && !flags.json) { - console.log(''); - for (const fnd of livenessLint) { - printWarning(`${fnd.where}: ${fnd.message}`); - console.log(chalk.dim(` ${fnd.hint}`)); - console.log(chalk.dim(` rule: ${fnd.rule}`)); - } - } - - // 3d-ter. Autonumber `{field}` interpolation lint. A format like - // `{plan_no}{000}` makes the referenced field part of the counter - // scope, so it must exist and be set at create time — otherwise the - // runtime throws (or, unlinted, silently mis-numbers). An unknown - // field is broken → fails the build; an optional field is fragile → - // advisory warning. Mirrors the broken/fragile two-level guardrail. - const autonumberLint = lintAutonumberFormats(result.data as Record); - const autonumberErrors = autonumberLint.filter((f) => f.severity === 'error'); - const autonumberWarnings = autonumberLint.filter((f) => f.severity === 'warning'); - if (autonumberErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'autonumber format validation failed', issues: autonumberErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Autonumber format validation failed (${autonumberErrors.length} issue${autonumberErrors.length > 1 ? 's' : ''})`); - for (const f of autonumberErrors) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - this.exit(1); - } - if (autonumberWarnings.length > 0 && !flags.json) { - console.log(''); - for (const f of autonumberWarnings) { - printWarning(`${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - } - - // 3d-quinquies. Contradictory uniqueness declarations (#3991). A column - // carrying BOTH a field-level `unique: true` and a single-column - // declared unique index has two intents, of which exactly one takes - // effect: since #3696 the field-level form is per-tenant while a - // declared index is platform-wide, so the global index wins and the - // tenant composite becomes unreachable. Advisory — the artifact is - // well-defined; the cost is a declaration that does nothing. Shares - // `lintUniqueDeclarations` with `os lint` so both agree. - const uniqueLint = lintUniqueDeclarations( - Array.isArray((result.data as Record).objects) - ? ((result.data as Record).objects as any[]) - : [], - ); - if (uniqueLint.length > 0 && !flags.json) { - console.log(''); - for (const f of uniqueLint) { - printWarning(`${f.path}: ${f.message}`); - if (f.fix) console.log(chalk.dim(` ${f.fix}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - } - - // 3d-quater. View-reference lint (#2554) — resolves form action targets - // and view-key collisions at build time. A `type:'form'` target that - // names a missing view or a LIST view opens a broken/blank form at - // runtime; a list/form key collision silently renames one view so - // references resolve to the OTHER. Both are broken → fail the build. - // This shifts objectui's runtime `viewKind` guard left to compile. - const viewRefLint = lintViewRefs(result.data as Record); - const viewRefErrors = viewRefLint.filter((f) => f.severity === 'error'); - const viewRefWarnings = viewRefLint.filter((f) => f.severity === 'warning'); - if (viewRefErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'view reference validation failed', issues: viewRefErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`View reference validation failed (${viewRefErrors.length} issue${viewRefErrors.length > 1 ? 's' : ''})`); - for (const f of viewRefErrors) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - this.exit(1); - } - if (viewRefWarnings.length > 0 && !flags.json) { - console.log(''); - for (const f of viewRefWarnings) { - printWarning(`${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - } - - // 3e. [ADR-0090 D7] Security-domain publish linter. Every error rule - // mirrors a runtime enforcement point (fail-closed OWD default, - // canonical enum, anchor binding gate, vocabulary freeze) — the lint - // moves the failure from a runtime deny to an author-time fix-it. - // Errors GATE the build (per ADR-0049 this is not advisory - // security); `info` findings are printed dimmed and never fatal. - if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...'); - const securityFindings = [ - ...validateSecurityPosture(result.data as Record), - // [ADR-0105 D6] Organization-axis red lines: no permission inheritance - // along the org tree, and business-unit trees stay org-internal. Same - // finding shape, same gate — an `error` here blocks exactly as a - // security-posture error does. - ...validateOrgAxisRedLines(result.data as Record), - ]; - const securityErrors = securityFindings.filter((f) => f.severity === 'error'); - const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); - if (securityErrors.length > 0) { - if (flags.json) { - await emitJson({ success: false, error: 'security posture validation failed', issues: securityErrors }, 0, { compact: true }); - this.exit(1); - } - console.log(''); - printError(`Security posture check failed (${securityErrors.length} issue${securityErrors.length > 1 ? 's' : ''})`); - for (const f of securityErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (securityAdvisories.length > 0 && !flags.json) { - console.log(''); - for (const f of securityAdvisories) { - printWarning(`${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - } - - // 3f. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when + // 3e. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when // `access-matrix.json` sits next to the config, the (permission set // × object) capability matrix derived from THIS build must match it // — a drift fails the build with a SEMANTIC diff ("'crm_admin' // gains delete on 'crm_lead'") until the snapshot is updated via // --update-access-matrix. An unchanged matrix auto-passes, so the // gate costs nothing until someone changes who-can-do-what. + // + // Not a registry rule: it reads (and with the flag, writes) a file + // next to the config rather than answering a question about the stack. { const matrixPath = path.join(path.dirname(absolutePath), 'access-matrix.json'); const currentMatrix = buildAccessMatrix(result.data as Record); @@ -657,11 +309,14 @@ export default class Compile extends Command { } } - // 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into + // 3f. Package docs (ADR-0046): compile flat `src/docs/*.md` into // `docs: DocSchema[]` and lint the combined set (flatness, // namespace-prefixed names, MDX/image ban, same-package link // resolution). Errors fail the build — the artifact is the // publish unit, so this IS the publish lint for docs. + // + // Not a registry rule: it reads `src/docs/` off disk, and the docs it + // collects there are an INPUT to the artifact, not just a check. if (!flags.json) printStep('Collecting package docs (ADR-0046)...'); const docsResult = collectAndLintDocs(absolutePath, result.data as Record); const docErrors = docsResult.issues.filter((i) => i.severity === 'error'); @@ -776,7 +431,10 @@ export default class Compile extends Command { handlersBundled: lowering.count, runtimeModule: runtimeBundle?.outputFileName ?? null, runtimeModuleSize: runtimeBundle?.size ?? 0, - warnings: widgetWarnings, + // The whole registry's advisory set, in the shape `os validate --json` + // reports. This key used to carry the widget rule's warnings alone — + // one gate out of the twenty-odd that raise them. + warnings: ruleAdvisories, // Same key `os validate --json` uses, so a CI consumer reads one shape // from either command rather than learning two. conversions: conversionNotices, @@ -790,8 +448,8 @@ export default class Compile extends Command { // 5. Summary console.log(''); printSuccess(`Build complete ${chalk.dim(`(${timer.display()})`)}`); - if (widgetWarnings.length > 0) { - printWarning(`${widgetWarnings.length} widget-binding warning(s) — see above`); + if (ruleAdvisories.length > 0) { + printWarning(`${ruleAdvisories.length} author-time warning(s) — see above`); } console.log(''); printMetadataStats(stats); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 03ba3f6042..271905f8b9 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -258,32 +258,23 @@ export const SCHEMAS: Record = { docsPath: 'ui/action', }, + // Kept as a redirect topic (mirroring content/docs/automation/workflows.mdx): + // there is NO standalone Workflow authoring type. The shape this entry used + // to teach (states[]/transitions[]/approvers) never existed in the spec — + // ADR-0019 folded approval processes into Flow, and the workflow service + // slot itself retired in #4451 (v17). workflow: { - name: 'Workflow', - description: 'State machine and approval process that governs record lifecycle transitions.', - required: [ - { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'object', type: 'string', description: 'Target object' }, - ], - optional: [ - { name: 'label', type: 'string', description: 'Display name' }, - { name: 'states', type: 'State[]', description: 'Defined workflow states' }, - { name: 'transitions', type: 'Transition[]', description: 'Allowed state transitions' }, - { name: 'approvers', type: 'ApproverConfig', description: 'Approval chain configuration' }, - ], - example: `{ - name: 'task_approval', - object: 'project_task', - label: 'Task Approval', - states: ['draft', 'pending', 'approved', 'rejected'], - transitions: [ - { from: 'draft', to: 'pending', action: 'submit' }, - { from: 'pending', to: 'approved', action: 'approve' }, - { from: 'pending', to: 'rejected', action: 'reject' }, - ], -}`, + name: 'Workflow (no standalone type)', + description: 'ObjectStack has no standalone Workflow authoring type. Use Flow for event-triggered or scheduled automation, an object validation rule of type "state_machine" for strict lifecycle transitions, and Approval nodes inside a flow for human approval pauses (ADR-0019).', + required: [], + optional: [], + example: `// No workflow metadata exists. Compose the live mechanisms instead: +// - Flow (type: 'record_change' | 'schedule' | 'screen') for automation +// - object validation rule { type: 'state_machine', ... } for transitions +// - a flow node of type 'approval' for human approval steps +// See: os explain flow`, related: ['object', 'flow', 'action'], - docsPath: 'automation/workflow', + docsPath: 'automation/workflows', }, trigger: { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index c0f8418a69..6d71845874 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -8,9 +8,8 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; -import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint'; -import { validateReferenceIntegrity } from '@objectstack/lint'; +import { runAuthoringRules } from '../lint/authoring-rules.js'; +import { resolveSduiManifest } from '../utils/sdui-manifest.js'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -135,7 +134,17 @@ function getViewLabel(view: any, viewPath: string): { label?: string; path: stri // ─── Lint Engine ──────────────────────────────────────────────────── -export function lintConfig(config: any): LintIssue[] { +export interface LintConfigOptions { + /** + * ADR-0080 SDUI component manifest, when the project ships one. Present, the + * JSX gate does full component/prop validation; absent, it stays parse-level. + * The `os lint` command resolves it; `scoreMetadata` deliberately does not — + * the scorer is a pure function of a stack and must not read the filesystem. + */ + sduiManifest?: unknown; +} + +export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue[] { const issues: LintIssue[] = []; const push = (issue: LintIssue | null) => { @@ -346,166 +355,34 @@ export function lintConfig(config: any): LintIssue[] { // objectstack-data/-ui skills. These double as the eval rubric (see score.ts). issues.push(...lintDataModel(objects)); - // ── Dashboard widget bindings (ADR-0021, issues #1719/#1721) ── - // Reference integrity (errors): widget `dataset`/`dimensions`/`values` and - // chartConfig axis/series fields must resolve against the declared - // datasets. Advisory shapes (warnings): e.g. a table/pivot widget whose - // binding resolves to count-only measures with no dimensions — almost - // always a record listing that belongs in an object-bound ListView - // (ADR-0017), not an analytics dataset. - for (const w of validateWidgetBindings(config)) { - issues.push({ - severity: w.severity, - rule: w.rule, - message: `${w.where}: ${w.message}`, - path: w.path, - fix: w.hint, - }); - } - - // ── Record-title contract (ADR-0079) ── - // titleFormat is retired (render-only template the server can't return or - // query) in favour of nameField; and an object with no resolvable title - // (no nameField/displayNameField and nothing derivable) ships records with - // no meaningful name. Both are advisory warnings — the auto-provision - // transform and the `Record #` floor keep a green build from ever - // shipping a fully title-less object (the ADR-0078 "not cloud-only" parity - // with cloud graph-lint). - for (const t of validateRecordTitle(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Semantic-role pointers (ADR-0085) ── - // stageField / highlightFields / Field.group are pointers into the object's - // field map; a dangling pointer is Zod-valid but silently inert at render - // time (the ADR-0078 completeness gate). All advisory — every consumer - // degrades gracefully. - for (const t of validateSemanticRoles(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Capability references (ADR-0066 ⑨) ── - // requiredPermissions naming a capability that is registered nowhere - // (no built-in, no permission set grants it, no sys_capability seed) is - // almost certainly a typo. Advisory — the reference fails closed at runtime, - // and the capability may legitimately be provided by another installed package. - for (const t of validateCapabilityReferences(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Security posture (ADR-0090 D7) ── - // The security-domain publish linter: unset/alias OWD, external dial wider - // than internal, wildcard VAMA, high-privilege everyone-suggested sets, the - // reserved word "role", and private-object read grants with no depth. Runs - // on the NORMALIZED (pre-zod) input here, so alias values that the schema - // gate would reject in `os compile` get a located fix-it instead of a Zod - // enum error. `error` findings gate `os compile`; `info` maps to suggestion. - for (const t of validateSecurityPosture(config)) { - issues.push({ - severity: t.severity === 'info' ? 'suggestion' : t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Organization-axis red lines (ADR-0105 D6) ── - // The org tree (`parent_organization_id`) is a REPORTING dimension. An RLS - // policy or sharing rule that walks it builds a second permission hierarchy — - // the dual-hierarchy mistake ADR-0057 D5 retired — and cannot widen Layer 0 - // anyway, so it grants nothing it appears to. Business-unit grants on - // platform-global objects are the other half: no org column to scope against - // means the grant spans every organization. - for (const t of validateOrgAxisRedLines(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Approval-node approvers (ADR-0090 D3 fallout) ── - // `{ type: 'role' }` resolves against the better-auth org-membership tier - // (owner/admin/member), NOT positions — a position name authored there - // silently routes the approval to nobody. Advisory: the fix-it points at - // `{ type: 'position' }` (sys_user_position). - for (const t of validateApprovalApprovers(config)) { - issues.push({ - severity: t.severity === 'info' ? 'suggestion' : t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Seed replay safety (framework#3434) ── - // Seeds are replayed on every boot / re-publish, so a `mode: 'insert'` dataset - // duplicates its table on every restart (the loader's insert path has no - // existing-row check). Advisory: the fix-it points at `ignore`/`upsert` + an - // `externalId` (single field, or a composite list for a join table). - for (const t of validateSeedReplaySafety(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Seed value vs state machine (framework#3433 follow-up) ── - // #3433 exempts seed writes from the `state_machine` rule, so a seeded status - // the FSM does not declare is no longer rejected at write time. Re-add that - // safety net at author time: a value outside the machine's declared states is - // almost certainly a typo. Advisory — the exemption itself is legitimate. - for (const t of validateSeedStateMachine(config)) { - issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, - }); - } - - // ── Reference integrity (issue #3583) ── - // One suite, one call site: object-name references `defineStack` does not - // cover, name-bound action surfaces, page-component field bindings, chart - // axes outside dashboards, navigation vs. granted access, and translation - // keys pointing at metadata that no longer exists. Every member resolves a - // NAME against what the stack declares — the class the HotCRM audit found - // shipping, where each instance parses, validates, and fails silently. - // Adding a rule to `REFERENCE_INTEGRITY_RULES` reaches this path with no - // edit here (assessment §5 D5 — the wiring drift this ends). - for (const t of validateReferenceIntegrity(config)) { + // ── The author-time rule registry (#4409) ── + // Everything above this line is `os lint`'s OWN rubric: naming, labels, + // structure, data-model conventions. Its `error` severity is a lint verdict, + // not a publish gate — `os build` has never rejected a camelCase object name. + // + // Everything below comes from the table the three authoring commands share. + // `os lint` used to hand-wire its own subset of it, and the subsets disagreed: + // it ran `validateApprovalApprovers` (which gates) that neither other command + // ran, and missed six gating rules that both of them ran — so it returned + // clean for stacks `os build` rejects AND rejected stacks `os build` ships. + // A pre-flight that disagrees with the gate in both directions is worse than + // no pre-flight: the only rational responses are to re-verify everything or + // to stop trusting it. + // + // The registry is `os lint`'s single call site into that set. Adding a rule + // there reaches this command with no edit here. Do NOT import a rule directly. + // + // `os lint` does not Zod-parse (a schema error is `os validate`'s verdict to + // give), so the registry runs both stack tiers against the normalized input — + // which is what this command already did for the reference-integrity suite + // and the security linter. + for (const f of runAuthoringRules('lint', { normalized: config, sduiManifest: opts.sduiManifest })) { issues.push({ - severity: t.severity, - rule: t.rule, - message: `${t.where}: ${t.message}`, - path: t.path, - fix: t.hint, + severity: f.severity === 'info' ? 'suggestion' : f.severity, + rule: f.rule, + message: `${f.where}: ${f.message}`, + path: f.path, + fix: f.hint, }); } @@ -577,7 +454,7 @@ export default class Lint extends Command { } const normalized = normalizeStackInput(config as Record); - const issues = lintConfig(normalized); + const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() }); // ── Package docs (ADR-0046) ── collected src/docs/*.md + inline docs: // flatness, namespace-prefixed names, MDX/image ban, link resolution. diff --git a/packages/cli/src/commands/migrate/meta.stored-flags.test.ts b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts new file mode 100644 index 0000000000..c5f860b8c7 --- /dev/null +++ b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4327 — the flag surface where `os migrate meta`'s two modes meet. + * + * One command, two subjects: `--from N` replays the ADR-0087 chain over an + * **author's source** (a config file, no database in reach), `--stored` replays + * it over **one deployment's** `sys_metadata` rows (a database, no config in + * reach). Everything below pins a boundary that a plausible-looking oclif + * declaration gets wrong. + */ +import { describe, expect, it } from 'vitest'; +import MigrateMeta, { storedOnlyFlagsIn } from './meta.js'; + +describe('storedOnlyFlagsIn (#4327)', () => { + it('reports the stored-only flags the operator typed', () => { + expect(storedOnlyFlagsIn(['--from', '16', '--apply'])).toEqual(['apply']); + expect(storedOnlyFlagsIn(['--from=16', '--type=view', '--force'])).toEqual(['force', 'type']); + expect(storedOnlyFlagsIn(['--from', '16', '-y'])).toEqual(['yes']); + }); + + it('says nothing about a run that typed none of them', () => { + expect(storedOnlyFlagsIn(['--from', '16', '--step', '--json'])).toEqual([]); + expect(storedOnlyFlagsIn([])).toEqual([]); + }); + + it('never double-reports --yes given both spellings', () => { + expect(storedOnlyFlagsIn(['--stored', '--yes', '-y'])).toEqual(['yes']); + }); + + it('reads argv, not the environment — an exported OS_DATABASE_URL is not a typed flag', () => { + // The trap that made `dependsOn` unusable: oclif fills `--database-url` + // from `OS_DATABASE_URL`, so a merely-exported env var would have counted + // as "provided" and broken `os migrate meta --from N` for anyone who has + // one set. Provenance comes from argv precisely so it cannot. + expect(storedOnlyFlagsIn(['--from', '16'])).toEqual([]); + expect(storedOnlyFlagsIn(['--stored', '--database-url', 'sqlite://x.db'])).toEqual(['database-url']); + }); +}); + +describe('MigrateMeta flag declarations (#4327)', () => { + const flags = MigrateMeta.flags as Record; + + it('does not declare --from required — that would reject every --stored run', () => { + expect(flags.from.required).not.toBe(true); + }); + + it('makes the authored-chain flags exclusive with --stored', () => { + // `--from` names a major an author wrote against; a stored row carries its + // own history and gets the full chain regardless. Accepting both would + // imply the stored pass honours a range it does not have. + for (const name of ['from', 'to', 'step', 'out']) { + expect(flags[name].exclusive).toContain('stored'); + } + }); + + it('leaves the stored-only flags free of dependsOn', () => { + // Enforced by `storedOnlyFlagsIn` instead: see the env-var case above. + for (const name of ['apply', 'yes', 'force', 'type', 'database-url']) { + expect(flags[name].dependsOn).toBeUndefined(); + } + }); + + it('defaults --apply off — preview is the only mode a bare run can have', () => { + expect(flags.apply.default).toBe(false); + expect(flags.stored.default).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts b/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts new file mode 100644 index 0000000000..249b10568c --- /dev/null +++ b/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * End-to-end acceptance for #4498's CLI half: `os migrate meta --stored` still + * covers `flow` rows after the command stopped threading `canonicalizeFlow`. + * + * #4454 wired flow coverage by resolving `automation` off the booted kernel in + * the command body and handing `canonicalizeStoredFlow` to + * `migrateStoredMetadata`. #4498 gave the protocol its own resolver — it is + * constructed with an accessor for the kernel's service table, which is the + * same table the inert engine registers into — so the command passes nothing + * and the redundant second route is gone. + * + * That is exactly the kind of removal a unit test cannot defend: every flag test + * still passes if the protocol silently fails to find the engine, and the only + * symptom is flow rows quietly reporting `skipped` again. So this boots the + * REAL stack the command boots (`bootSchemaStack` + + * `buildDataMigrationPlugins({ automation: true })`), seeds a pre-17 flow row, + * and asserts the rewrite lands in the database. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; + +/** + * `SchemaStack.kernel` is untyped, so a type argument is a TS2347 — the slot's + * contract is stated on the RESULT instead. Narrowing, not erasing: `: any` + * here would switch off checking on every `ql.*` call below while looking + * identical to code that has it (the `slot-lookup` rule's whole point). + */ +const engineOf = (stack: { kernel: any }): IObjectQLEngine => + stack.kernel.getService('objectql') as IObjectQLEngine; + +/** Elevated so the seed write bypasses RLS on a system object. */ +const SYSTEM = { context: { isSystem: true } }; + +const ARTIFACT = { + id: 'stored_flow_smoke', + name: 'Stored Flow Smoke', + objects: [{ name: 'sfs_lead', fields: { title: { type: 'text' } } }], +}; + +/** + * A pre-17 flow: `delete_record` carrying `config.filters`, which the + * `flow-node-crud-filter-alias` conversion (toMajor 11) renames to `filter`. + * Written straight into `sys_metadata`, bypassing today's schema gate — exactly + * like a row saved years ago under an older protocol. + */ +const LEGACY_FLOW = { + name: 'sfs_purge', + label: 'Purge Stale Leads', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'n0', type: 'start', label: 'Start' }, + { + id: 'n1', + type: 'delete_record', + label: 'Purge', + config: { objectName: 'sfs_lead', filters: { title: 'stale' } }, + }, + ], + edges: [{ id: 'e1', source: 'n0', target: 'n1' }], +}; + +describe('os migrate meta --stored — the protocol resolves the engine itself (#4498)', () => { + let dir: string; + let dbFile: string; + const savedEnv: Record = {}; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-stored-flow-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + mkdirSync(join(dir, 'data'), { recursive: true }); + dbFile = join(dir, 'data', 'app.db'); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT)); + + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + savedEnv.NODE_ENV = process.env.NODE_ENV; + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + process.env.NODE_ENV = savedEnv.NODE_ENV; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('rewrites a pre-17 flow row with NO canonicalizeFlow passed by the command', async () => { + const stack = await bootSchemaStack({ + databaseUrl: `file:${dbFile}`, + projectRoot: dir, + extraPlugins: await buildDataMigrationPlugins({ automation: true }), + }); + try { + const ql = engineOf(stack); + await ql.insert('sys_metadata', { + type: 'flow', + name: 'sfs_purge', + state: 'active', + metadata: JSON.stringify(LEGACY_FLOW), + }, SYSTEM); + + const protocol: any = stack.kernel.getService('protocol'); + + // The command's exact call since #4498 — no `canonicalizeFlow`. + const report = await protocol.migrateStoredMetadata({ + apply: true, + types: ['flow'], + actor: 'os migrate meta --stored', + }); + + // Before the resolver this row came back `skipped` with "no automation + // service is reachable". Asserted as the REASON rather than as a bare + // count, so a regression here says what went wrong instead of just + // "expected 1 to be 0". + expect( + report.rows + .filter((r: any) => r.outcome === 'skipped' || r.outcome === 'failed') + .map((r: any) => `${r.outcome}: ${r.reason}`), + ).toEqual([]); + expect(report.skipped).toBe(0); + expect(report.failed).toBe(0); + expect(report.rewritten).toBe(1); + + // …and the bytes on disk actually moved. + const [row] = await ql.find('sys_metadata', { + where: { type: 'flow', name: 'sfs_purge', state: 'active' }, + }, SYSTEM); + const stored = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + const node = stored.nodes.find((n: any) => n.id === 'n1'); + expect(node.config).toEqual({ objectName: 'sfs_lead', filter: { title: 'stale' } }); + expect(node.config).not.toHaveProperty('filters'); + + // The write-back must NOT carry the schema's defaults (#4454): persisting + // a `version` / `runAs` the author never wrote would pin this row to + // today's value while untouched rows follow tomorrow's. + expect(stored).not.toHaveProperty('runAs'); + expect(stored.edges[0]).not.toHaveProperty('isDefault'); + + // A second pass has nothing left to do — the finish line the whole + // feature exists to provide. + const rerun = await protocol.migrateStoredMetadata({ types: ['flow'] }); + expect(rerun.scanned).toBe(1); + expect(rerun.canonical).toBe(1); + expect(rerun.pending).toBe(0); + } finally { + await stack.shutdown(); + } + }, 120_000); + + it('without the automation plugin the row is skipped with the reason, never counted done', async () => { + // The honest negative: the coverage comes from the engine being present, + // not from the report defaulting to optimistic. + const stack = await bootSchemaStack({ + databaseUrl: `file:${dbFile}`, + projectRoot: dir, + extraPlugins: await buildDataMigrationPlugins(), + }); + try { + const ql = engineOf(stack); + await ql.insert('sys_metadata', { + type: 'flow', + name: 'sfs_purge', + state: 'active', + metadata: JSON.stringify(LEGACY_FLOW), + }, SYSTEM); + + const protocol: any = stack.kernel.getService('protocol'); + const report = await protocol.migrateStoredMetadata({ apply: true, types: ['flow'] }); + + expect(report.rewritten).toBe(0); + expect(report.skipped).toBe(1); + expect(report.rows[0].reason).toMatch(/no automation service is reachable/); + } finally { + await stack.shutdown(); + } + }, 120_000); +}); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index f9c0f81a3b..d5b9a8dae8 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -3,6 +3,7 @@ import { Args, Command, Flags } from '@oclif/core'; import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { createInterface } from 'node:readline'; import chalk from 'chalk'; import { ObjectStackDefinitionSchema, @@ -25,10 +26,45 @@ import { createTimer, emitJson, } from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; +import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; +import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; // non-interactive → require --yes + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer: string = await new Promise((res) => rl.question(question, res)); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} /** The protocol major that introduced the per-deployment value-shape gates. */ const VALUE_SHAPE_GATE_MAJOR = 17; +/** Flags that mean something only in `--stored` mode (#4327). */ +const STORED_ONLY_FLAGS = ['apply', 'yes', 'force', 'type', 'database-url'] as const; + +/** + * Which stored-only flags the operator actually TYPED. + * + * oclif's own `dependsOn` cannot answer this: a boolean with `default: false` + * and an `env`-backed string both read as "provided" to it, so declaring + * `dependsOn: ['stored']` on `--database-url` would make a merely-exported + * `OS_DATABASE_URL` break `os migrate meta --from N`. The raw argv is the only + * signal for intent, so the guard reads that. + */ +export function storedOnlyFlagsIn(argv: readonly string[]): string[] { + const typed = STORED_ONLY_FLAGS.filter((f) => + argv.some((a) => a === `--${f}` || a.startsWith(`--${f}=`)), + ) as string[]; + if (argv.includes('-y') && !typed.includes('yes')) typed.push('yes'); + return typed; +} + interface PendingDataMigration { /** `sys_migration` row id the run records. */ id: string; @@ -120,16 +156,35 @@ function printPendingDataMigrations(pending: PendingDataMigration[]): void { * unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot * the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure * can be bisected to the exact major. + * + * ## `--stored`: the same chain, over data at rest (#4327) + * + * The default mode above has one subject — the **author's source**, read from a + * config file, with no database in reach. `--stored` has the other: the + * `sys_metadata` rows of **one deployment**. Same chain, same canonical target, + * opposite ends of the contract, which is why they share a command rather than + * splitting into two that would both be called "migrate the metadata". + * + * They are mutually exclusive for the same reason: `--from` describes a + * protocol major an author wrote against, and a stored row already carries its + * own history — the stored pass replays the full chain (retired entries + * included) because a row at rest has no author to ask. See + * {@link MigrateMeta.runStored}. */ export default class MigrateMeta extends Command { static override description = - 'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3).'; + 'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3). ' + + 'With --stored, replay it over this deployment\'s sys_metadata rows instead of an authored config.'; static override examples = [ '$ os migrate meta --from 10', '$ os migrate meta --from 10 --step', '$ os migrate meta --from 11 --to 12 --json', '$ os migrate meta --from 10 --out migrated.stack.json', + '$ os migrate meta --stored', + '$ os migrate meta --stored --apply', + '$ os migrate meta --stored --apply --yes --json', + '$ os migrate meta --stored --type view --type object', ]; static override args = { @@ -138,23 +193,97 @@ export default class MigrateMeta extends Command { static override flags = { from: Flags.integer({ - description: 'The protocol major the metadata was authored against.', - required: true, + description: 'The protocol major the metadata was authored against (required without --stored).', + exclusive: ['stored'], }), to: Flags.integer({ description: `Target protocol major (defaults to this runtime's, ${PROTOCOL_MAJOR}).`, + exclusive: ['stored'], }), step: Flags.boolean({ description: 'Print a per-hop checkpoint (for per-major verify / bisection).', default: false, + exclusive: ['stored'], + }), + out: Flags.string({ + description: 'Write the migrated stack as a JSON snapshot to this path.', + exclusive: ['stored'], + }), + stored: Flags.boolean({ + description: + "Canonicalize this deployment's sys_metadata rows in place instead of an authored config " + + '(read-only preview unless --apply).', + default: false, + }), + 'database-url': Flags.string({ + description: '--stored: database to canonicalize (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + apply: Flags.boolean({ + description: '--stored: rewrite the rows (default is a read-only preview)', + default: false, + }), + yes: Flags.boolean({ + char: 'y', + description: '--stored: skip the --apply confirmation prompt', + default: false, + }), + force: Flags.boolean({ + description: '--stored: apply even when another process is using the database (SQLite occupancy check)', + default: false, + }), + type: Flags.string({ + description: '--stored: restrict to this metadata type (repeatable; default: every type)', + multiple: true, }), - out: Flags.string({ description: 'Write the migrated stack as a JSON snapshot to this path.' }), json: Flags.boolean({ description: 'Output the machine-readable migration result as JSON.' }), }; async run(): Promise { const { args, flags } = await this.parse(MigrateMeta); const timer = createTimer(); + + if (flags.stored) { + await this.runStored(flags, timer); + return; + } + + // A stored-only flag typed without `--stored` is refused rather than + // ignored: `--apply` in particular reads as "and write it", and the + // authored-source mode has nothing to write to. + const typed = storedOnlyFlagsIn(this.argv); + if (typed.length > 0) { + const message = + `${typed.map((f) => `--${f}`).join(', ')} only appl${typed.length > 1 ? 'y' : 'ies'} to ` + + '`os migrate meta --stored` (the pass over a deployment\'s sys_metadata rows). ' + + 'The authored-source chain reads a config file and writes nothing but --out.'; + if (flags.json) { + await emitJson({ error: 'stored_only_flag', flags: typed, message }, 0, { compact: true }); + this.exit(1); + return; + } + printError(message); + this.exit(1); + return; + } + + // `--from` is required for the authored-source chain and meaningless for + // `--stored` (a row carries its own history), so it is validated here + // rather than declared `required` — oclif would reject `--stored` runs. + if (flags.from === undefined) { + const message = + 'Missing required flag --from (the protocol major your metadata was authored against). ' + + 'To canonicalize a deployment\'s stored rows instead, run `os migrate meta --stored`.'; + if (flags.json) { + await emitJson({ error: 'missing_from_major', message }, 0, { compact: true }); + this.exit(1); + return; + } + printError(message); + this.exit(1); + return; + } + const fromMajor = flags.from; const toMajor = flags.to ?? PROTOCOL_MAJOR; if (!flags.json) printHeader('Migrate · meta'); @@ -169,13 +298,13 @@ export default class MigrateMeta extends Command { // D2 pass. Running the D2 pass here would leave the chain's diff empty. const normalized = normalizeStackInput(config as Record, { convert: false }); - if (!flags.json) printStep(`Replaying chain: protocol ${flags.from} → ${toMajor}…`); - const result = applyMetaMigrations(normalized, flags.from, toMajor); + if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`); + const result = applyMetaMigrations(normalized, fromMajor, toMajor); // Prove the migrated stack is schema-valid — the "generated, provably valid // diff" the consumer agent reviews (ADR-0087 D3/D5). const parsed = ObjectStackDefinitionSchema.safeParse(result.stack); - const specChanges = composeSpecChanges(flags.from, toMajor); + const specChanges = composeSpecChanges(fromMajor, toMajor); const dataMigrations = pendingDataMigrations(result.stack, result.fromMajor, result.toMajor); if (flags.json) { @@ -205,7 +334,7 @@ export default class MigrateMeta extends Command { } printInfo(`Config: ${chalk.white(absolutePath)}`); - printInfo(`Chain: protocol ${flags.from} → ${toMajor} (runtime ${PROTOCOL_VERSION})`); + printInfo(`Chain: protocol ${fromMajor} → ${toMajor} (runtime ${PROTOCOL_VERSION})`); console.log(''); if (result.applied.length === 0 && result.todos.length === 0) { @@ -281,4 +410,197 @@ export default class MigrateMeta extends Command { this.exit(1); } } + + /** + * `os migrate meta --stored` — canonicalize this deployment's `sys_metadata` + * rows so the read-path conversion chain has a finish line (#4327). + * + * #4317 made every stored-row rehydration seam replay the full ADR-0087 chain, + * so a row written under any past protocol *reads* canonical forever. The rows + * stayed legacy, though: the chain re-lowers them on every load and each one + * warns once per boot. This run ends that — same chain, same policy, but the + * result is written back through the normal write path (history row, checksum, + * mutation projectors) with `source: 'migrate-stored'`. + * + * **Preview by default; `--apply` is the only writing mode.** That is the + * house rule its two siblings already keep (`os migrate value-shapes`, + * `os migrate files-to-references`, #3617's "a dry run changes nothing"), and + * the reason applies with more force here: this rewrites *metadata*, so a + * surprise run would move every affected row's checksum and mint a history + * entry against each. + * + * Nothing gates on this having run — #3855's conclusion that operator-run + * migrations cannot be relied on still holds, and the read path stays the + * guarantee. What a run buys is hygiene plus one thing that was previously + * unobtainable: **an operator can now assert it.** A second pass reporting + * every row canonical exits 0; a deployment with work left exits 1, so "my + * metadata is on protocol N" becomes a CI check instead of a belief. + */ + private async runStored( + flags: { + json: boolean; + apply: boolean; + yes: boolean; + force: boolean; + type?: string[]; + 'database-url'?: string; + }, + timer: { elapsed: () => number; display: () => string }, + ): Promise { + const apply = flags.apply; + if (!flags.json) printHeader('Migrate · meta --stored'); + + // Occupancy gate — an apply run rewrites rows, so a live writer on the same + // SQLite file is the same hazard `os migrate files-to-references --apply` + // refuses for. Probed BEFORE boot (afterwards our own pool is what the probe + // finds) and before the prompt, so nobody confirms something we then refuse. + const occupancy = await probeMigrationTarget(flags['database-url']); + if (occupancy.status === 'busy' && apply && !flags.force) { + if (flags.json) { + await emitJson({ + error: 'database_busy', + database: occupancy.filename, + signal: occupancy.signal, + detail: occupancy.detail, + hint: OCCUPANCY_HINT, + }, 0, { compact: true }); + this.exit(1); + return; + } + printError(describeOccupancy(occupancy)); + printWarning(OCCUPANCY_HINT); + this.exit(1); + return; + } + if (occupancy.status === 'busy' && !flags.json) { + printWarning(apply + ? `--force: ${describeOccupancy(occupancy)} Rewriting anyway — the live process may save metadata mid-run.` + : `${describeOccupancy(occupancy)} The preview below writes nothing, but a live process may ` + + 'be saving metadata while it runs.'); + } + if (occupancy.status === 'unknown' && !flags.json) { + printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); + } + + if (apply && !flags.yes) { + if (flags.json || !process.stdin.isTTY) { + if (flags.json) { + await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); + this.exit(1); + return; + } + printWarning( + 'Apply mode rewrites sys_metadata rows in place — each rewritten row gets a new checksum ' + + 'and a history entry. Re-run with --yes to confirm, or run without --apply to preview.', + ); + this.exit(1); + return; + } + const ok = await confirm( + chalk.bold('\nRewrite the stored metadata rows that carry a pre-protocol shape? [y/N] '), + ); + if (!ok) { + printInfo('Aborted — nothing written.'); + return; + } + } + + if (!flags.json) { + printStep(apply ? 'Booting data stack (APPLY mode)…' : 'Booting data stack (preview only)…'); + } + + let stack; + try { + // `PlatformObjectsPlugin` for `sys_metadata` and its history/audit + // siblings, plus the automation engine in INERT mode so `flow` rows are + // covered too (#4454) — flow-node conversions need its executor registry + // for the conflict guard, and `armRuntime: false` means taking it arms + // nothing. No storage adapter: unlike the file migration, nothing here + // reads bytes. + stack = await bootSchemaStack({ + ...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}), + extraPlugins: await buildDataMigrationPlugins({ automation: true }), + }); + } catch (error: any) { + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); return; } + printError(error.message || String(error)); + this.exit(1); + return; + } + + // Collected rather than thrown: `this.exit()` raises an oclif ExitError, + // which the catch below would then report as a bare "EEXIT: 1" over the + // real message. Decide the code here, exit after the stack is down. + let exitCode = 0; + try { + const protocol: any = stack.kernel.getService('protocol'); + if (typeof protocol?.migrateStoredMetadata !== 'function') { + throw new Error( + 'No metadata protocol on this stack — cannot walk sys_metadata. ' + + 'Run this from a project root whose stack registers the ObjectQL engine.', + ); + } + + const { formatStoredMigrationReport, storedMigrationClean } = + await import('@objectstack/metadata-protocol'); + + // No `canonicalizeFlow` is threaded from here. The automation engine + // canonicalizes `flow` rows — it holds the executor registry ADR-0078's + // conflict guard needs (#4454) — and the protocol resolves it from the + // kernel's service table itself (#4498), which is the same table the + // inert engine this command boots registers into. Passing it again would + // be a second route to one capability, and the two would drift. + // Absent (an older stack, or a boot that skipped it), flow rows keep + // reporting `skipped` with the reason rather than being counted done. + const report = await protocol.migrateStoredMetadata({ + apply, + ...(flags.type && flags.type.length > 0 ? { types: flags.type } : {}), + actor: 'os migrate meta --stored', + }); + const clean = storedMigrationClean(report); + if (!clean) exitCode = 1; + + if (flags.json) { + await emitJson({ database: stack.dbLabel, ...report, clean, duration: timer.elapsed() }); + } else { + printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + console.log(''); + console.log(formatStoredMigrationReport(report).join('\n')); + console.log(''); + + if (report.scanned === 0) { + // Exits 0 — nothing is wrong — but does not claim a clean bill for a + // database it never read a row from. + printInfo(`No stored metadata to examine ${chalk.dim(`(${timer.display()})`)}`); + } else if (clean && apply) { + printSuccess( + `Stored metadata is on protocol ${report.protocol} — rewrote ${report.rewritten} row(s) ` + + `${chalk.dim(`(${timer.display()})`)}`, + ); + } else if (clean) { + printSuccess( + `Stored metadata is already on protocol ${report.protocol} — nothing to rewrite ` + + `${chalk.dim(`(${timer.display()})`)}`, + ); + } else if (report.failed > 0) { + printError( + `${report.failed} row(s) could not be rewritten. They keep reading canonically through the ` + + 'chain, so nothing is broken — but their stored bytes stay legacy until the reason above is fixed.', + ); + } else { + printWarning( + `${report.pending} row(s) carry a pre-protocol shape. They read canonically today (the chain ` + + 'runs on every load); re-run with --apply to persist it.', + ); + } + } + } catch (error: any) { + exitCode = 1; + if (flags.json) await emitJson({ error: error.message }, 0, { compact: true }); + else printError(error.message || String(error)); + } finally { + await stack.shutdown(); + } + if (exitCode !== 0) this.exit(exitCode); + } } diff --git a/packages/cli/src/commands/reference-integrity-wiring.test.ts b/packages/cli/src/commands/reference-integrity-wiring.test.ts deleted file mode 100644 index 0037dc0898..0000000000 --- a/packages/cli/src/commands/reference-integrity-wiring.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Structural wiring guard for the suite's whole reason to exist (#3583 §5 D5). -// -// ## Why a test and not a comment -// -// The defect this catches is not a wrong result — it is a MISSING CALL, and a -// missing call produces no failing assertion anywhere. Every command's own -// tests pass, the rule's unit tests pass, and the only symptom is that -// `os validate`, `os lint` and `os compile` disagree about the same stack. -// -// That is not hypothetical. `validateReadonlyFlowWrites` was hand-wired into -// `validate` and `compile` and never into `lint` from #3425 until #4394 — and -// because it GATES (`flow-update-readonly-field` is an `error`), `os lint` -// spent that whole time returning clean for stacks `os compile` refuses. The -// suite's own header had been naming it as the standing proof of the drift the -// suite exists to end, which is a comment doing a test's job. #4394 removed -// that instance; this file removes the failure MODE, so the next rule cannot -// repeat it silently (#4384). -// -// ## The invariant -// -// `os lint` ⊇ `os compile`'s gate set. `lint` is the cheap pre-flight and -// `compile` is the gate; a green `lint` followed by a red `compile` makes the -// pre-flight worthless — and for an agent it is worse than worthless, because -// the remaining options are re-verifying everything (slow) or learning to -// distrust the signal (dangerous). -// -// ## Why it scans source -// -// vitest inlines imports through its transform, so a spy on `@objectstack/lint` -// cannot prove which symbols a command file actually reaches for — the same -// reason `lazy-deps.test.ts` scans `src/` rather than probing a module cache. -// Behavioural coverage of what the suite CONTAINS lives in -// `@objectstack/lint`'s `reference-integrity-suite.test.ts`; this file guards -// only the seam between that suite and the three call sites. - -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, it, expect } from 'vitest'; -import { REFERENCE_INTEGRITY_RULES } from '@objectstack/lint'; - -const commandsDir = dirname(fileURLToPath(import.meta.url)); - -/** The three commands that must hold a stack to the same author-time bar. */ -const AUTHORING_COMMANDS = ['validate.ts', 'lint.ts', 'compile.ts'] as const; - -const sourceOf = (file: string) => readFileSync(join(commandsDir, file), 'utf8'); - -describe('reference-integrity wiring (#3583 §5 D5, #4384)', () => { - it.each(AUTHORING_COMMANDS)('%s runs the suite', (file) => { - expect(sourceOf(file)).toMatch(/\bvalidateReferenceIntegrity\s*\(/); - }); - - // The regression itself. A second, per-rule call site is how a rule ends up - // on two commands out of three, and it always starts with importing that rule - // by name — so the import is what this asserts on. Catching the call instead - // would miss the window between adding the import and adding the second call. - it.each(AUTHORING_COMMANDS)('%s does not import a suite member directly', (file) => { - const source = sourceOf(file); - const reached = REFERENCE_INTEGRITY_RULES.map((r) => r.name).filter((name) => - new RegExp( - String.raw`^\s*import\s[^;]*\b${name}\b[^;]*from\s*['"]@objectstack/lint['"]`, - 'm', - ).test(source), - ); - expect( - reached, - `${file} imports suite member(s) directly — run them via validateReferenceIntegrity instead, ` + - `or all three commands will drift the way validateReadonlyFlowWrites did (#4394)`, - ).toEqual([]); - }); - - // Guards the guard: were the suite ever emptied or the export renamed, the - // assertions above would pass vacuously while checking nothing. - it('the suite is non-empty, so the assertions above are not vacuous', () => { - expect(REFERENCE_INTEGRITY_RULES.length).toBeGreaterThan(0); - // The rule whose absence from `os lint` is the reason this file exists. - expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toContain('validateReadonlyFlowWrites'); - }); -}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 1c0d0007cf..60091e9a4d 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,9 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { existsSync, readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { join, dirname } from 'node:path'; +import { dirname } from 'node:path'; import chalk from 'chalk'; import { ZodError } from 'zod'; import { @@ -15,25 +13,10 @@ import { type ConversionNotice, } from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; -import { validateStackExpressions } from '@objectstack/lint'; -import { validateListViewMode } from '@objectstack/lint'; -import { validateViewContainers } from '@objectstack/lint'; -import { validateWidgetBindings } from '@objectstack/lint'; -import { validateDashboardActionRefs } from '@objectstack/lint'; -import { validateFilterTokens } from '@objectstack/lint'; -import { validateReferenceIntegrity } from '@objectstack/lint'; -import { validateResponsiveStyles } from '@objectstack/lint'; -import { validateJsxPages, validateReactPages, validatePageSourceStyling } from '@objectstack/lint'; -import { validateCapabilityReferences } from '@objectstack/lint'; -import { validateVisibilityPredicates } from '@objectstack/lint'; -import { validateSecurityPosture, validateOrgAxisRedLines } from '@objectstack/lint'; -import { validateFlowTriggerReadiness } from '@objectstack/lint'; -import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; -import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; -import { lintUniqueDeclarations } from '../lint/data-model-rules.js'; -import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; -import { lintViewRefs } from '../utils/lint-view-refs.js'; +import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '../lint/authoring-rules.js'; +import { resolveSduiManifest } from '../utils/sdui-manifest.js'; import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; +import { collectAndLintDocs } from '../utils/collect-docs.js'; import { printHeader, printKV, @@ -66,7 +49,7 @@ export default class Validate extends Command { const { args, flags } = await this.parse(Validate); const timer = createTimer(); - + if (!flags.json) { printHeader('Validate'); } @@ -75,7 +58,7 @@ export default class Validate extends Command { // 1. Load configuration if (!flags.json) printStep('Loading configuration...'); const { config, absolutePath, duration } = await loadConfig(args.config); - + if (!flags.json) { printKV('Config', absolutePath); printKV('Load time', `${duration}ms`); @@ -92,10 +75,10 @@ export default class Validate extends Command { onConversionNotice: (n) => conversionNotices.push(n), }); // [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so drop - // silently. PRE-parse for the same reason the visibility rule below is: - // the parse is what strips them, so `result.data` no longer carries the - // key the author actually wrote. Computed here rather than down in the - // warnings section so the `--json` path reports it too — the + // silently. PRE-parse for the same reason the registry's `normalized`-tier + // rules are: the parse is what strips them, so `result.data` no longer + // carries the key the author actually wrote. Computed here rather than + // down in the warnings section so the `--json` path reports it too — the // "computed, then discarded" shape this file already had to fix once. const unknownKeyWarnings = [ ...lintUnknownStackKeys(normalized as Record, ObjectStackDefinitionSchema), @@ -119,528 +102,58 @@ export default class Validate extends Command { this.exit(1); } - // 2b. Expression validation (ADR-0032 §1a/1b) — the same gate `os build` - // runs, brought to the read-only check so authors catch it without - // emitting an artifact. CEL predicates in actions/validations/flows/ - // sharing/hooks are checked for syntax AND that `record.` - // references resolve on the target object. This is what catches a - // BARE field ref (`done` instead of `record.done`) that would - // otherwise silently hide an action on every record (#2183/#2185). - if (!flags.json) printStep('Validating expressions (ADR-0032)...'); - const exprIssues = validateStackExpressions(result.data as Record); - const exprErrors = exprIssues.filter((i) => i.severity !== 'warning'); - const exprWarnings = exprIssues.filter((i) => i.severity === 'warning'); - - if (exprErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: exprErrors, - warnings: exprWarnings, - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Expression validation failed (${exprErrors.length} issue${exprErrors.length > 1 ? 's' : ''})`); - for (const i of exprErrors.slice(0, 50)) { - console.log(` • ${i.where}: ${i.message}`); - console.log(chalk.dim(` source: \`${i.source}\``)); - } - this.exit(1); - } - - // 2c. ADR-0053 list-view navigation modes — `userFilters`/`quickFilters` - // on an object list view ("views" mode) are silently dropped: the - // object-list schema (ObjectListViewSchema) OMITS them, so this is - // checked on `normalized` (PRE-parse) — `result.data` has already had - // the field stripped. They belong to a page list ("filters" mode). - // See objectui #2219 and ADR-0053 phase 4. - if (!flags.json) printStep('Checking list-view navigation modes (ADR-0053)...'); - const listViewFindings = validateListViewMode(normalized as Record); - const listViewErrors = listViewFindings.filter((f) => f.severity === 'error'); - - if (listViewErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: listViewErrors, - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`List-view mode check failed (${listViewErrors.length} issue${listViewErrors.length > 1 ? 's' : ''})`); - for (const f of listViewErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 2d. View container shape — a flat list-view object in `views: []` - // parses to an EMPTY container (ViewSchema strips unknown keys), so - // the schema step passes while zero views register and the Console - // silently renders nothing. Checked on `normalized` (PRE-parse) — - // `result.data` has already had the flat keys stripped. - if (!flags.json) printStep('Checking view container shape...'); - const viewContainerFindings = validateViewContainers(normalized as Record); - const viewContainerErrors = viewContainerFindings.filter((f) => f.severity === 'error'); - - if (viewContainerErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: viewContainerErrors, - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`View container check failed (${viewContainerErrors.length} issue${viewContainerErrors.length > 1 ? 's' : ''})`); - for (const f of viewContainerErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3. Dashboard widget reference integrity (issue #1721) — a semantic - // cross-reference pass the protocol schema cannot express: every - // widget's `dataset`/`dimensions`/`values` and chartConfig - // axis/series fields must resolve against the declared datasets - // (ADR-0021). Errors fail validation; warnings are advisory. - if (!flags.json) printStep('Checking dashboard widget bindings (ADR-0021)...'); - const widgetFindings = validateWidgetBindings(result.data as Record); - const widgetErrors = widgetFindings.filter((f) => f.severity === 'error'); - const widgetWarnings = widgetFindings.filter((f) => f.severity === 'warning'); - - if (widgetErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: widgetErrors, - warnings: widgetWarnings, - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Dashboard widget integrity failed (${widgetErrors.length} issue${widgetErrors.length > 1 ? 's' : ''})`); - for (const f of widgetErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3a-bis. Dashboard action/route reference integrity (ADR-0049 for - // references, #3367) — a header/widget action names a `script`/`modal` - // target that resolves to no defined action, or a `url` target that - // matches no in-app route. Nothing else flags it, so it ships as a - // button that renders and silently does nothing on click (a false - // affordance). Dead script/modal targets are errors (fail open at - // runtime); unresolved url routes are advisory warnings. - if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...'); - const actionRefFindings = validateDashboardActionRefs(result.data as Record); - const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error'); - const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); - if (actionRefErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: actionRefErrors, - warnings: [...widgetWarnings, ...actionRefWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`); - for (const f of actionRefErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (!flags.json) { - for (const w of actionRefWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3a-ter. Filter placeholder resolvability (#3574) — a filter value like - // `{current_user}` resolves in no vocabulary, reaches the data engine - // as a literal, matches nothing, and the surface renders empty with - // no error anywhere. Silent-zero is indistinguishable from a genuine - // zero, so it survives human review; and an AI author reads the 0 as - // a successful query. Caught here because authoring time is the only - // place the diagnostic can still reach the author. - if (!flags.json) printStep('Checking filter placeholders (#3574)...'); - const filterTokenFindings = validateFilterTokens(result.data as Record); - const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error'); - if (filterTokenErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: filterTokenErrors, - warnings: [...widgetWarnings, ...actionRefWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`); - for (const f of filterTokenErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3a-quater. Object-name + action-name reference integrity (#3583). The - // reference sites `defineStack` does not cover: action-param - // `reference`/`objectOverride`, dashboard filter `optionsFrom.object`, - // nav `requiresObject` gates, and the name-bound action surfaces - // (`bulkActions`/`rowActions`, page quick-actions, nav action items). - // Plus page-component field bindings and the chart surfaces outside - // dashboards (report charts, list-view charts, dataset-bound page - // chart components) — same ADR-0021 semantic layer, where an axis - // naming a raw field instead of a measure renders an empty series. - // All are plain strings in the schema, so a name resolving to nothing - // parses, ships, and fails silently at runtime. An unprefixed miss is - // a typo (error); a platform-prefixed name no known package registers - // is advisory (a third-party package may still provide it). - // Translation bundles get the same treatment in reverse: a key naming - // a field/view/action/section that no longer exists — or an option - // keyed by its display label instead of its stored value — resolves - // to nothing and renders the source string (advisory: inert, not - // broken). - if (!flags.json) printStep('Checking object & action references (#3583)...'); - const refFindings = validateReferenceIntegrity(result.data as Record); - const refErrors = refFindings.filter((f) => f.severity === 'error'); - const refWarnings = refFindings.filter((f) => f.severity === 'warning'); - if (refErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: refErrors, - warnings: [...widgetWarnings, ...actionRefWarnings, ...refWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Reference integrity check failed (${refErrors.length} issue${refErrors.length > 1 ? 's' : ''})`); - for (const f of refErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - if (!flags.json) { - for (const w of refWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's - // responsiveStyles must be scopable (needs an `id`), reference real - // CSS properties + design tokens, and carry a `large` base; - // Tailwind-in-className silently does nothing. Same bar for - // hand-authored and AI-generated pages (ADR-0019). - if (!flags.json) printStep('Checking SDUI styling (ADR-0065)...'); - const styleFindings = validateResponsiveStyles(result.data as Record); - const styleErrors = styleFindings.filter((f) => f.severity === 'error'); - const styleWarnings = styleFindings.filter((f) => f.severity === 'warning'); - - if (styleErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: styleErrors, - warnings: [...widgetWarnings, ...styleWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`SDUI styling check failed (${styleErrors.length} issue${styleErrors.length > 1 ? 's' : ''})`); - for (const f of styleErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3b. JSX-source pages (ADR-0080) — a kind:'jsx' page's `source` is - // parsed (never executed) and compiled to the SDUI tree at save - // time. Parse it now so malformed source fails loudly (ADR-0078) - // instead of being stored and breaking only at render. - if (!flags.json) printStep('Checking JSX-source pages (ADR-0080)...'); - // Optional component manifest (ADR-0080): if the project ships a - // `sdui.manifest.json` (generated from the registry's public tier), the - // gate does full component/prop validation; otherwise parse-level. - let sduiManifest: unknown; - try { - const mp = join(process.cwd(), 'sdui.manifest.json'); - if (existsSync(mp)) sduiManifest = JSON.parse(readFileSync(mp, 'utf8')); - if (!sduiManifest) { - // Fall back to the manifest shipped inside @objectstack/console - // (built from objectui's public-tier registry; cli already deps it). - const cp = createRequire(import.meta.url).resolve('@objectstack/console/dist/sdui.manifest.json'); - if (existsSync(cp)) sduiManifest = JSON.parse(readFileSync(cp, 'utf8')); - } - } catch { /* fall back to parse-level */ } - const jsxFindings = validateJsxPages( - result.data as Record, - sduiManifest ? { manifest: sduiManifest as never } : {}, - ); - const jsxErrors = jsxFindings.filter((f) => f.severity === 'error'); - const jsxWarnings = jsxFindings.filter((f) => f.severity === 'warning'); - - if (jsxErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: jsxErrors, - warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`JSX-source page check failed (${jsxErrors.length} issue${jsxErrors.length > 1 ? 's' : ''})`); - for (const f of jsxErrors.slice(0, 50)) { - console.log(` \u2022 ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3c. React-source pages (ADR-0081) — a kind:'react' page's `source` is - // real React executed at render. Transpile it now (Sucrase, never - // executed) so syntax errors fail loudly at build, not at render. - if (!flags.json) printStep('Checking React-source pages (ADR-0081)...'); - const reactFindings = validateReactPages(result.data as Record); - const reactErrors = reactFindings.filter((f) => f.severity === 'error'); - if (reactErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: reactErrors, - warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`React-source page check failed (${reactErrors.length} issue${reactErrors.length > 1 ? 's' : ''})`); - for (const f of reactErrors.slice(0, 50)) { - console.log(` \u2022 ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } - - // 3d. React-source page PROPS are checked by `REFERENCE_INTEGRITY_RULES` - // (step 3a above), not from here (#4340 follow-up). They ran from - // this call site ALONE, so `os lint` and `os compile` accepted a - // react page whose every field binding was stale — including the - // gating ones. That is `validateReadonlyFlowWrites`' divergence - // (#4394) one surface over. The input is unchanged: the suite is - // handed the same `result.data` this block passed. - - // 3e. Source-tier page styling (ADR-0065): Tailwind className in a - // kind:'html'/'react' page source silently no-ops (the build never - // scans authored metadata) — warn with the inline-style fix. - if (!flags.json) printStep('Checking source-page styling (ADR-0065)...'); - const sourceStyleFindings = validatePageSourceStyling(result.data as Record); - const sourceStyleWarnings = sourceStyleFindings.filter((f) => f.severity === 'warning'); - if (!flags.json) { - for (const w of sourceStyleWarnings.slice(0, 50)) { - console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3f. Capability references (ADR-0066 ⑨): a requiredPermissions entry - // naming a capability registered nowhere (no built-in, no permission - // set grants it, no sys_capability seed) is almost certainly a typo — - // it fails closed at runtime. Advisory: the capability may legitimately - // be provided by another installed package. - if (!flags.json) printStep('Checking capability references (ADR-0066)...'); - const capFindings = validateCapabilityReferences(result.data as Record); - const capWarnings = capFindings.filter((f) => f.severity === 'warning'); - if (!flags.json) { - for (const w of capWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3g. Auto-launched flow trigger wiring (2026-07-17 third-party eval): - // a record-change flow whose start-node objectName matches nothing - // never fires — silently. Also nudges auto-triggered flows to declare - // an explicit deployment status (the schema default is 'draft', and - // draft flows DO still fire — ambiguous intent). Advisory: objects - // may come from other installed packages. - if (!flags.json) printStep('Checking flow trigger wiring...'); - const flowReadinessFindings = validateFlowTriggerReadiness(normalized as Record); - const flowReadinessWarnings = flowReadinessFindings.filter((f) => f.severity === 'warning'); - if (!flags.json) { - for (const w of flowReadinessWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - - // 3g-bis. Flow template path references (#3426) used to be checked here by - // hand. It is a reference rule — a `{record.}` token resolved - // against the bound object's declared fields — so it now runs as a - // member of REFERENCE_INTEGRITY_RULES in step 3 above, which reaches - // `os lint` and `os compile` at the same time. Those two accepted a - // flow whose filter token the runtime refuses (#3810) for as long as - // this call site was the only one. - - // 3e3. [#3782] The four authoring lints that live in the CLI itself rather - // than in `@objectstack/lint`. Every other gate on this command is a - // `@objectstack/lint` import, so these four were only ever reachable - // from `compile.ts` — `os build` ran them, `os validate` did not, and - // the drift went unnoticed while all of their findings were advisory. - // Two of them already GATE the build (`lintAutonumberFormats`, - // `lintViewRefs` emit `severity: 'error'`), which made this command - // report a clean stack that `os build` then rejected — exactly the - // contract this command exists to uphold. Severity handling mirrors - // `compile.ts` per lint, so the two surfaces agree by construction. - if (!flags.json) printStep('Running authoring lints (#3782)...'); - - // Flow authoring anti-patterns (#1874). Advisory today; `severity: 'error'` - // is honoured so a blocking rule (#3760's `flow-runas-unscoped`) gates here - // the moment it gates the build, with no further wiring. - const flowLint = lintFlowPatterns(result.data as Record); - const flowLintErrors = flowLint.filter((f) => f.severity === 'error'); - const flowLintWarnings = flowLint.filter((f) => f.severity !== 'error'); - - // Liveness author-warnings — an authored property the ledger marks - // dead-and-misleading or experimental. Advisory only, never fatal. - const livenessLint = lintLivenessProperties(result.data as Record); - - // Autonumber `{field}` interpolation — an unknown field is broken (error); - // an optional one is fragile (warning). - const autonumberLint = lintAutonumberFormats(result.data as Record); - const autonumberErrors = autonumberLint.filter((f) => f.severity === 'error'); - const autonumberWarnings = autonumberLint.filter((f) => f.severity !== 'error'); - - // View references (#2554) — a form action target naming a missing or LIST - // view, and list/form view-key collisions. Both are broken → error. - const viewRefLint = lintViewRefs(result.data as Record); - const viewRefErrors = viewRefLint.filter((f) => f.severity === 'error'); - const viewRefWarnings = viewRefLint.filter((f) => f.severity !== 'error'); - - // Contradictory uniqueness declarations (#3991) — a column carrying both a - // field-level `unique: true` and a single-column declared unique index has - // two intents, of which exactly one takes effect. Advisory. Mapped into the - // `{ where, hint }` shape the shared renderer below expects; the rule lives - // in `lint/data-model-rules.ts` so `os lint` reports the same finding. - const uniqueLintWarnings = lintUniqueDeclarations( - Array.isArray((result.data as Record).objects) - ? ((result.data as Record).objects as any[]) - : [], - ).map((f) => ({ where: f.path, message: f.message, hint: f.fix ?? '', rule: f.rule, severity: 'warning' as const })); - - const authoringLintErrors = [...flowLintErrors, ...autonumberErrors, ...viewRefErrors]; - const authoringLintWarnings = [ - ...flowLintWarnings, - ...livenessLint, - ...autonumberWarnings, - ...viewRefWarnings, - ...uniqueLintWarnings, - ]; - if (authoringLintErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: authoringLintErrors, - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`Authoring lint failed (${authoringLintErrors.length} issue${authoringLintErrors.length > 1 ? 's' : ''})`); - for (const f of authoringLintErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule}`)); - } - this.exit(1); - } - if (!flags.json) { - for (const f of authoringLintWarnings.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${f.where}: ${f.message}`)); - console.log(chalk.dim(` ${f.hint}`)); - } - } + // 3. The author-time rule registry (#4409). Every rule the three authoring + // commands share — expressions, view shape, widget/action/filter/name + // references, SDUI styling, page sources, security posture, the CLI's + // own authoring lints — runs from ONE table, so `os validate`, + // `os build` and `os lint` hold a stack to the same bar by construction. + // Before it, each command hand-wired its own subset: 23 of 26 rules ran + // on some strict subset of the three, and `os build` — the command that + // PUBLISHES — was the weakest gate of the three. + // + // Which rules run, on which stack tier, and why any of them is scoped + // is declared in `lint/authoring-rules.ts`. Do not add a call site here. + const registered = authoringRulesFor('validate'); + if (!flags.json) printStep(`Running author-time rules (${registered.length})...`); + const findings = runAuthoringRules('validate', { + normalized: normalized as Record, + parsed: result.data as Record, + sduiManifest: resolveSduiManifest(), + }); + const { errors: ruleErrors, advisories: ruleAdvisories } = splitBySeverity(findings); - // 3f. [ADR-0090 D7] Security posture — the same gate `os compile`/`os build` - // run. Without it here, `os validate` passed a stack (e.g. a custom - // object with no explicit sharingModel) that the build then rejected, - // breaking this command's contract of being the artifact-free run of - // the same gates. Errors gate; advisories print dimmed. - if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...'); - const securityFindings = [ - ...validateSecurityPosture(result.data as Record), - // [ADR-0105 D6] Organization-axis red lines: no permission inheritance - // along the org tree, and business-unit trees stay org-internal. Same - // finding shape, same gate — an `error` here blocks exactly as a - // security-posture error does. - ...validateOrgAxisRedLines(result.data as Record), - ]; - const securityErrors = securityFindings.filter((f) => f.severity === 'error'); - const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); - if (securityErrors.length > 0) { + if (ruleErrors.length > 0) { + // Every failing rule reports at once. The command used to exit at the + // first failing gate, so an author with three unrelated problems fixed + // them in three round trips and could not see how deep the hole went. if (flags.json) { await emitJson({ valid: false, - errors: securityErrors, + errors: ruleErrors, + warnings: ruleAdvisories, duration: timer.elapsed(), }); this.exit(1); } console.log(''); - printError(`Security posture check failed (${securityErrors.length} issue${securityErrors.length > 1 ? 's' : ''})`); - for (const f of securityErrors.slice(0, 50)) { + printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`); + for (const f of ruleErrors.slice(0, 50)) { console.log(` • ${f.where}: ${f.message}`); console.log(chalk.dim(` ${f.hint}`)); console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); } this.exit(1); } - if (!flags.json) { - for (const f of securityAdvisories.slice(0, 50)) { - console.log(chalk.yellow(` ⚠ ${f.where}: ${f.message}`)); - console.log(chalk.dim(` ${f.hint}`)); - } - } - // 3h. [#3366] Installable-provider preflight — the shift-left of the + // 3b. [#3366] Installable-provider preflight — the shift-left of the // `serve`-time capability check. `os validate` previously only checked // the `requires` tokens against the vocabulary (ADR-0066), never // whether each token's provider is resolvable in the active edition. A // token whose provider has NO installable version here (e.g. `ai` → // @objectstack/service-ai, cloud-only) fails; absent-but-installable is // an advisory `pnpm add` hint. Mirrors the `os build` gate exactly. + // + // Not a registry rule: it reads `node_modules`, not the stack. if (!flags.json) printStep('Checking capability providers (#3366)...'); const capProviderPreflight = preflightRequiredCapabilities({ requires: Array.isArray((config as { requires?: unknown[] }).requires) @@ -670,6 +183,39 @@ export default class Validate extends Command { this.exit(1); } + // 3c. Package docs (ADR-0046) — flatness, namespace-prefixed names, the + // MDX/image ban, same-package link resolution. `os build` has always + // FAILED on a doc error (the artifact is the publish unit, so that is + // the publish lint for docs) while this command never ran it: the same + // "build rejects what validate accepts" hole #4409 found among the + // metadata rules, one gate over. It went unnoticed because the parity + // guard keyed on the `lint*`/`validate*` naming convention and this + // one is called `collectAndLintDocs`. + // + // Not a registry rule: it reads `src/docs/*.md` off disk. + if (!flags.json) printStep('Checking package docs (ADR-0046)...'); + const docsResult = collectAndLintDocs(absolutePath, result.data as Record); + const docErrors = docsResult.issues.filter((i) => i.severity === 'error'); + const docWarnings = docsResult.issues.filter((i) => i.severity !== 'error'); + if (docErrors.length > 0) { + if (flags.json) { + await emitJson({ + valid: false, + errors: docErrors, + warnings: ruleAdvisories, + duration: timer.elapsed(), + }); + this.exit(1); + } + console.log(''); + printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`); + for (const i of docErrors.slice(0, 50)) { + console.log(` • ${i.path}: ${i.message}`); + console.log(chalk.dim(` rule: ${i.rule}`)); + } + this.exit(1); + } + // 4. Collect and display stats const stats = collectMetadataStats(config); @@ -682,13 +228,11 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - // `refWarnings` carries the whole reference-integrity suite, which now - // includes the flow-template-path rule this list used to name directly. - // It was absent here before: on a CLEAN run `--json` reported none of - // the suite's warnings, though the failure path (above) and the console - // both did. Same shape of bug as the dropped errors — computed, then - // discarded — so it is fixed rather than reproduced under a new name. - warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...authoringLintWarnings, ...unknownKeyWarnings, ...securityAdvisories, ...capProviderWarnings], + // One advisory list for the whole registry. This used to be a + // hand-maintained concatenation of per-gate arrays, and it leaked + // twice: warnings computed and then dropped from `--json` while the + // console printed them. A single list cannot drift from itself. + warnings: [...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings], conversions: conversionNotices, specVersionGap: specGap, duration: timer.elapsed(), @@ -705,15 +249,6 @@ export default class Validate extends Command { warnings.push(w.message); } - // ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root. - // Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/ - // `visibility` into `visibleWhen` during parse, so `result.data` no longer - // carries the alias the author actually wrote. - const visibilityFindings = validateVisibilityPredicates(normalized as Record); - for (const f of visibilityFindings) { - warnings.push(`${f.where}: ${f.message} — ${f.hint}`); - } - // [#3786] Undeclared object/field keys — computed pre-parse above, // alongside `normalized`, for the same reason. warnings.push(...unknownKeyWarnings); @@ -724,18 +259,18 @@ export default class Validate extends Command { for (const n of conversionNotices) { warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`); } - for (const i of exprWarnings) { - warnings.push(`${i.where}: ${i.message}`); - } - for (const f of widgetWarnings) { - warnings.push(`${f.where}: ${f.message}`); - } - for (const f of styleWarnings) { + + // Every advisory the registry raised. All of them feed `--strict` now: + // before, roughly half were printed inline and invisible to it, so + // `--strict` failed or passed depending on which gate happened to raise + // the finding — a second, quieter version of the same coverage drift. + for (const f of ruleAdvisories) { warnings.push(`${f.where}: ${f.message}`); } - for (const f of jsxWarnings) { - warnings.push(`${f.where}: ${f.message}`); + for (const w of docWarnings) { + warnings.push(`${w.path}: ${w.message}`); } + if (stats.objects === 0) { warnings.push('No objects defined — this stack has no data model'); } diff --git a/packages/cli/src/lint/authoring-rules.ts b/packages/cli/src/lint/authoring-rules.ts new file mode 100644 index 0000000000..f9e7c62fea --- /dev/null +++ b/packages/cli/src/lint/authoring-rules.ts @@ -0,0 +1,628 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The author-time rule registry — WHICH rules `os validate`, `os build` and + * `os lint` run, declared as data, with a written reason for every narrowing + * (#4409). + * + * ## Why this exists + * + * Each of the three authoring commands grew its own import list and its own + * call site per rule. Nothing connected them, so "which rules run here?" was + * answerable only by reading three 800-line files and diffing them by eye — and + * the answer drifted every time a rule landed. At the point this registry was + * written, 23 of the 26 hand-wired rules ran on some strict subset of the three, + * and nine of those could emit `severity: 'error'`. The worst direction was not + * the obvious one: `os build` was the WEAKEST of the three gates, so it emitted + * an artifact for stacks `os validate` or `os lint` refuses. A flow whose + * expression approver does not parse (`approval-expression-invalid`, an `error`) + * built and published green — only `os lint` stopped it, and CI usually runs the + * other two. + * + * That failure mode had already been fixed four times, one instance at a time: + * the reference-integrity suite (#3583 §5 D5), the four CLI-local authoring + * lints that ran on `build` alone (#3782), `validateReadonlyFlowWrites` missing + * from `lint` (#4384/#4394), and the wiring guard that followed (#4402). Each + * repair removed an instance and left the MODE — a rule's command coverage was + * whatever its author remembered to type, and forgetting was silent. This file + * replaces "remembering" with a table, and the guard in + * `commands/authoring-rule-wiring.test.ts` makes a narrowing an explicit, + * reasoned edit instead of an omission. + * + * ## The invariant + * + * **Any rule that can emit `error` runs on all three commands.** A gate is only + * as strong as the weakest command an author or CI happens to run, so a gating + * rule with partial coverage is not a stricter check — it is a coin flip. + * + * An `advisory` rule (never emits `error`) MAY be scoped to fewer commands, but + * only with a `scopeReason` recorded here. The distinction that matters is not + * cost, it is consequence: a missing advisory costs the author a hint, a missing + * gate ships broken metadata. + * + * Cost, as it turns out, argues for almost nothing. The heavy dependencies + * (`typescript` ~9 MB, `sucrase` ~1.5 MB) are already lazy and load only when a + * stack actually carries the metadata that needs them — a contract pinned by + * `@objectstack/lint`'s `lazy-deps.test.ts`. `validateReactPageProps`, the + * heaviest rule of the set, has run on all three commands as a suite member + * since #4340 without anyone noticing a cost. So `os lint` stays light on the + * stacks that do not use those surfaces, whether or not the rules are wired. + * + * ## Adding a rule + * + * Append one entry here. It reaches all three commands at once and nothing else + * needs editing. Do NOT import the rule into a command file — the wiring guard + * fails on a direct import, because that is precisely how a rule ends up running + * on two commands out of three. + * + * ## What is NOT in here + * + * This registry covers the rules the three commands SHARE. Two neighbouring + * families are deliberately outside it, and the guard's ratchet lists them by + * name so the boundary stays a decision rather than an oversight: + * + * - **`os lint`'s own style rubric** (snake_case names, missing labels, the + * data-model best-practice sweep, docs, i18n coverage). Its `error` severity + * is a LINT verdict, not a publish gate — `os build` has never rejected a + * camelCase object name and making it do so is a product decision, not a + * wiring fix. + * - **Gates that need more than the stack** — the capability-provider preflight + * (reads `node_modules`), package docs (reads `src/docs/`), the access-matrix + * snapshot (reads/writes a file next to the config). They are I/O, not pure + * metadata rules, and each is wired where its input exists. + */ + +import { + validateStackExpressions, + validateListViewMode, + validateViewContainers, + validateWidgetBindings, + validateDashboardActionRefs, + validateFilterTokens, + validateReferenceIntegrity, + validateResponsiveStyles, + validateJsxPages, + validateReactPages, + validatePageSourceStyling, + validateCapabilityReferences, + validateFlowTriggerReadiness, + validateApprovalApprovers, + validateRecordTitle, + validateSemanticRoles, + validateFormLayout, + validateSeedReplaySafety, + validateSeedStateMachine, + validateVisibilityPredicates, + validateSecurityPosture, + validateOrgAxisRedLines, + validateActionLocations, +} from '@objectstack/lint'; +import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; +import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; +import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; +import { lintViewRefs } from '../utils/lint-view-refs.js'; +import { lintUniqueDeclarations } from './data-model-rules.js'; + +type AnyRec = Record; + +// ─── Types ────────────────────────────────────────────────────────── + +/** The three commands that hold a stack to the same author-time bar. */ +export const AUTHORING_COMMANDS = ['validate', 'build', 'lint'] as const; +export type AuthoringCommand = (typeof AUTHORING_COMMANDS)[number]; + +/** `error` gates. `warning` advises. `info` is a suggestion (`os lint` grades it as one). */ +export type AuthoringSeverity = 'error' | 'warning' | 'info'; + +/** + * The one finding shape all three commands render. Rules whose own return type + * predates it are adapted at their registry entry, so the commands hold one + * type instead of a twenty-way union. + */ +export interface AuthoringFinding { + severity: AuthoringSeverity; + /** Stable diagnostic rule id (used by docs, allowlists and `--json` consumers). */ + rule: string; + /** Human-readable location, e.g. `object "leave_request"`. */ + where: string; + /** Config path, e.g. `objects[3].sharingModel`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +/** + * `gating` = the rule can emit `severity: 'error'`, so it MUST run on all three + * commands. `advisory` = it never does, and may be scoped with a reason. + * + * The claim is not taken on trust: the wiring guard reads each `advisory` rule's + * own source and fails if it emits an `error`. That check is the reason the tier + * is worth declaring — #3760 promoted a `lintFlowPatterns` rule from advisory to + * gating, and nothing anywhere asked whether its command coverage should follow. + */ +export type AuthoringRuleTier = 'gating' | 'advisory'; + +/** + * Which tier of the stack a rule reads. + * + * - `normalized` — the `normalizeStackInput` output, BEFORE the Zod parse. The + * rules that need it check keys the parse strips (a flat list view in + * `views: []`, `userFilters` on an object list view, a `visibleOn` alias): by + * the time `result.data` exists the evidence is gone. + * - `parsed` — the post-parse stack, where defaults are filled and shapes are + * settled. + * + * `os lint` never parses (it is the cheap pre-flight; a schema error is + * `os validate`'s verdict to give), so it runs BOTH tiers on the normalized + * stack. Every rule here is written to tolerate that — it is what `os lint` + * already did for the reference-integrity suite and the security linter. + */ +export type AuthoringRuleInputTier = 'normalized' | 'parsed'; + +/** Per-run inputs a rule may need beyond the stack itself. */ +export interface AuthoringRuleContext { + /** ADR-0080 SDUI component manifest, when the project ships one. */ + sduiManifest?: unknown; +} + +export interface AuthoringRule { + /** The exported function's name — the id the wiring guard asserts on. */ + name: string; + tier: AuthoringRuleTier; + input: AuthoringRuleInputTier; + /** Which commands run it. Must be all three when `tier` is `gating`. */ + commands: readonly AuthoringCommand[]; + /** Repo-relative path to the rule's implementation (the guard verifies the tier claim against it). */ + source: string; + /** REQUIRED when `commands` is not all three: why this rule is scoped. */ + scopeReason?: string; + run: (stack: AnyRec, ctx: AuthoringRuleContext) => readonly AuthoringFinding[]; +} + +/** Every command runs every rule unless an entry says otherwise. */ +const ALL: readonly AuthoringCommand[] = AUTHORING_COMMANDS; + +/** + * `ExprIssue` is the one rule finding that carries no rule id of its own — it + * predates the `{ rule, path, hint }` shape every other rule settled on. Given + * one here so `os lint --json` and the docs can name it like any other. + */ +export const EXPRESSION_INVALID = 'expression-invalid'; + +// ─── The registry ─────────────────────────────────────────────────── + +/** + * Every author-time rule the three commands share, in the order their findings + * are reported. + */ +export const AUTHORING_RULES: readonly AuthoringRule[] = [ + // ADR-0032 §1a/1b — CEL predicates in actions/validations/flows/sharing/hooks + // are parsed for syntax AND checked that each `record.` resolves. This + // is what catches a BARE field ref (`done` instead of `record.done`) that + // would otherwise silently hide an action on every record (#2183/#2185). + { + name: 'validateStackExpressions', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-expressions.ts', + run: (stack) => + validateStackExpressions(stack).map((i) => ({ + severity: i.severity ?? 'error', + rule: EXPRESSION_INVALID, + where: i.where, + path: i.where, + message: i.message, + hint: `source: \`${i.source}\``, + })), + }, + // ADR-0053 — `userFilters`/`quickFilters` on an object list view ("views" + // mode) are silently dropped: `ObjectListViewSchema` omits them, so this must + // read the pre-parse tier or the evidence is already gone. + { + name: 'validateListViewMode', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-list-view-mode.ts', + run: (stack) => validateListViewMode(stack), + }, + // A flat list-view object in `views: []` parses to an EMPTY container + // (ViewSchema strips unknown keys): the schema step passes, zero views + // register, and the Console renders nothing. Pre-parse for the same reason. + { + name: 'validateViewContainers', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-view-containers.ts', + run: (stack) => validateViewContainers(stack), + }, + // ADR-0021 (#1719/#1721) — a widget's `dataset`/`dimensions`/`values` and its + // chartConfig axis/series must resolve against the declared datasets. + { + name: 'validateWidgetBindings', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-widget-bindings.ts', + run: (stack) => validateWidgetBindings(stack), + }, + // ADR-0049 / #3367 — a header or widget action naming a `script`/`modal` + // target that resolves to no defined action ships a button that renders and + // silently does nothing on click. Unresolved `url` routes stay advisory. + { + name: 'validateDashboardActionRefs', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-dashboard-action-refs.ts', + run: (stack) => validateDashboardActionRefs(stack), + }, + // #3574 — a filter value like `{current_user}` resolves in no vocabulary, + // reaches the data engine as a literal and matches nothing. The surface + // renders empty with no error, and a silent zero is indistinguishable from a + // genuine one at review time. + { + name: 'validateFilterTokens', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-filter-tokens.ts', + run: (stack) => validateFilterTokens(stack), + }, + // The reference-integrity suite (#3583 §5 D5) — itself a registry, of the + // rules that answer "does this name resolve to anything?". It reached all + // three commands before this file existed; it is an entry here so the two + // registries compose instead of competing, and so its members are covered by + // the same guard as everything else. + { + name: 'validateReferenceIntegrity', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/reference-integrity-suite.ts', + run: (stack) => validateReferenceIntegrity(stack), + }, + // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an + // `id`), name real CSS properties + design tokens, and carry a `large` base. + { + name: 'validateResponsiveStyles', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-responsive-styles.ts', + run: (stack) => validateResponsiveStyles(stack), + }, + // ADR-0080 — a `kind:'jsx'` page's `source` is parsed (never executed) and + // compiled to the SDUI tree at save time, so malformed source must fail loudly + // here (ADR-0078) instead of being stored and breaking only at render. + { + name: 'validateJsxPages', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-jsx-pages.ts', + run: (stack, ctx) => + validateJsxPages(stack, ctx.sduiManifest ? { manifest: ctx.sduiManifest as never } : {}), + }, + // ADR-0081 — a `kind:'react'` page's `source` is real React executed at + // render. Transpiled here (Sucrase, never executed) so a syntax error fails at + // author time, not at render. Lazy: only a stack with such a page pays. + { + name: 'validateReactPages', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-react-pages.ts', + run: (stack) => validateReactPages(stack), + }, + // ADR-0065, source tier — Tailwind `className` in a `kind:'html'`/`'react'` + // page silently no-ops (the build never scans authored metadata). + { + name: 'validatePageSourceStyling', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-page-source-styling.ts', + run: (stack) => validatePageSourceStyling(stack), + }, + // ADR-0066 ⑨ — a `requiredPermissions` entry naming a capability registered + // nowhere fails closed at runtime. Advisory: another installed package may + // legitimately provide it. + { + name: 'validateCapabilityReferences', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-capability-references.ts', + run: (stack) => validateCapabilityReferences(stack), + }, + // A record-change flow whose start-node objectName matches nothing never + // fires — silently. Reads the pre-parse tier so an author sees what they + // wrote. Advisory: the object may come from another installed package. + { + name: 'validateFlowTriggerReadiness', + tier: 'advisory', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-flow-trigger-readiness.ts', + run: (stack) => validateFlowTriggerReadiness(stack), + }, + // ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the + // better-auth org-membership tier, not positions, so a position name authored + // there routes the approval to nobody; and an expression approver that does + // not parse can never resolve. The rule whose absence from `os build` and + // `os validate` was #4409's worked example: it gates, and it ran on `os lint` + // alone, so a broken approval flow built and published green. + { + name: 'validateApprovalApprovers', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-approval-approvers.ts', + run: (stack) => validateApprovalApprovers(stack), + }, + // ADR-0079 — `titleFormat` is retired in favour of `nameField`, and an object + // with no resolvable title ships records with no meaningful name. Advisory: + // auto-provision and the `Record #` floor keep it from ever being fatal. + { + name: 'validateRecordTitle', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-record-title.ts', + run: (stack) => validateRecordTitle(stack), + }, + // ADR-0085 — `stageField` / `highlightFields` / `Field.group` are pointers + // into the object's field map; a dangling one is Zod-valid and silently inert + // at render. Advisory: every consumer degrades gracefully. + { + name: 'validateSemanticRoles', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-semantic-roles.ts', + run: (stack) => validateSemanticRoles(stack), + }, + // #2578 / #4449 — a form section's field reference that resolves to nothing + // (silently not rendered) and an absolute `colSpan` under a per-surface + // derived column count. Advisory: the renderer skips the unknown field and + // clamps the span, so nothing is broken — but each is almost certainly an + // authoring mistake, and until #4449 this rule ran on no command at all. + // Pure structured-metadata walk (no lazy dependency), so wiring it to all + // three costs nothing measurable. + { + name: 'validateFormLayout', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-form-layout.ts', + run: (stack) => validateFormLayout(stack), + }, + // ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no + // `locations` and that no view places by name renders on no surface at all. + // objectui#3142 made that measurable: four renderers used to show an + // undeclared action anyway, and now none does. Advisory: a view in another + // installed package may be the one placing it, and `locations: []` (the + // documented headless shape) is deliberately never flagged. + { + name: 'validateActionLocations', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-action-locations.ts', + run: (stack) => validateActionLocations(stack), + }, + // framework#3434 — seeds replay on every boot, so a `mode: 'insert'` dataset + // duplicates its table on every restart. + { + name: 'validateSeedReplaySafety', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-seed-replay-safety.ts', + run: (stack) => validateSeedReplaySafety(stack), + }, + // framework#3433 follow-up — #3433 exempts seed writes from the + // `state_machine` rule, so a seeded status the FSM does not declare is no + // longer rejected at write time. Re-added at author time; advisory, because + // the exemption itself is legitimate. + { + name: 'validateSeedStateMachine', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-seed-state-machine.ts', + run: (stack) => validateSeedStateMachine(stack), + }, + // ADR-0089 D3b — deprecated visibility aliases and a mis-layered binding root. + // Pre-parse: the schema folds `visibleOn`/`visibility` into `visibleWhen` + // during parse, so the alias the author wrote is gone from `result.data`. + { + name: 'validateVisibilityPredicates', + tier: 'advisory', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-visibility-predicates.ts', + run: (stack) => validateVisibilityPredicates(stack), + }, + // #1874 — flow authoring anti-patterns. Advisory by default; a finding marked + // `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the + // runtime REFUSES to execute), plus `flow-branch-label-unmatched` and + // `flow-default-edge-with-condition` (#4414 — a declaration that is inert, so + // the route silently differs from what the author wrote). The bar for + // promoting one is stated at the top of `lint-flow-patterns.ts`. + { + name: 'lintFlowPatterns', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/cli/src/utils/lint-flow-patterns.ts', + run: (stack) => + lintFlowPatterns(stack).map((f) => ({ + severity: f.severity ?? 'warning', + rule: f.rule, + where: f.where, + path: f.where, + message: f.message, + hint: f.hint, + })), + }, + // The spec-liveness loop on the author side: a property the ledger marks + // dead-and-misleading or experimental is set hopefully and does nothing. + // Ledger-driven (entries opt in via `authorWarn`), so it is high-signal and + // never fatal. + { + name: 'lintLivenessProperties', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/cli/src/utils/lint-liveness-properties.ts', + run: (stack) => + lintLivenessProperties(stack).map((f) => ({ + severity: 'warning' as const, + rule: f.rule, + where: f.where, + path: f.where, + message: f.message, + hint: f.hint, + })), + }, + // A format like `{plan_no}{000}` makes the referenced field part of the + // counter scope, so it must exist and be set at create time. Unknown field → + // broken (error); optional field → fragile (warning). + { + name: 'lintAutonumberFormats', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/cli/src/utils/lint-autonumber-formats.ts', + run: (stack) => + lintAutonumberFormats(stack).map((f) => ({ + severity: f.severity, + rule: f.rule, + where: f.where, + path: f.where, + message: f.message, + hint: f.hint, + })), + }, + // #2554 — a `type:'form'` action target naming a missing or LIST view opens a + // broken form at runtime; a list/form view-key collision silently renames one + // view so references resolve to the OTHER. Both are broken. + { + name: 'lintViewRefs', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/cli/src/utils/lint-view-refs.ts', + run: (stack) => + lintViewRefs(stack).map((f) => ({ + severity: f.severity, + rule: f.rule, + where: f.where, + path: f.where, + message: f.message, + hint: f.hint, + })), + }, + // #3991 — a column carrying BOTH a field-level `unique: true` and a + // single-column declared unique index has two intents, of which exactly one + // takes effect (the global index wins; the tenant composite is unreachable). + { + name: 'lintUniqueDeclarations', + tier: 'advisory', + input: 'parsed', + commands: ['validate', 'build'], + source: 'packages/cli/src/lint/data-model-rules.ts', + scopeReason: + "`os lint` already reports this rule through `lintDataModel`, which calls it directly as R10 of " + + 'its best-practice sweep — registering it for `lint` as well would report every finding twice. ' + + 'This is coverage recorded, not coverage missing: all three commands report the rule.', + run: (stack) => + lintUniqueDeclarations(Array.isArray(stack.objects) ? (stack.objects as unknown[]) : []).map((f) => ({ + severity: f.severity === 'suggestion' ? ('info' as const) : f.severity, + rule: f.rule, + where: f.path, + path: f.path, + message: f.message, + hint: f.fix ?? '', + })), + }, + // ADR-0090 D7 — the security-domain publish linter. Every `error` rule mirrors + // a runtime enforcement point (fail-closed OWD default, canonical enum, anchor + // binding gate, vocabulary freeze), moving the failure from a runtime deny to + // an author-time fix-it. Per ADR-0049 this is not advisory security. + { + name: 'validateSecurityPosture', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-security-posture.ts', + run: (stack) => validateSecurityPosture(stack), + }, + // ADR-0105 D6 — the org tree is a REPORTING dimension. An RLS policy or + // sharing rule that walks it builds a second permission hierarchy (the + // dual-hierarchy mistake ADR-0057 D5 retired) and cannot widen Layer 0 anyway, + // so it grants nothing it appears to. + { + name: 'validateOrgAxisRedLines', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-org-axis-red-lines.ts', + run: (stack) => validateOrgAxisRedLines(stack), + }, +]; + +// ─── Runner ───────────────────────────────────────────────────────── + +/** The stack tiers a command has in hand when it runs the registry. */ +export interface AuthoringRuleRun extends AuthoringRuleContext { + /** `normalizeStackInput` output — pre-Zod-parse. Always required. */ + normalized: AnyRec; + /** + * Post-Zod-parse stack. Omitted by `os lint`, which does not parse; `parsed` + * rules then read `normalized` (see `AuthoringRuleInputTier`). + */ + parsed?: AnyRec; +} + +/** The rules `command` runs, in registry order. */ +export function authoringRulesFor(command: AuthoringCommand): readonly AuthoringRule[] { + return AUTHORING_RULES.filter((r) => r.commands.includes(command)); +} + +/** + * Run every rule registered for `command` and return the concatenated findings + * (empty = clean). + * + * Findings are collected across ALL rules rather than short-circuiting at the + * first failing one. The commands used to exit at the first failing gate, which + * meant an author with three unrelated problems fixed them in three round trips + * and could not tell how deep the hole went. One report per run is also what + * makes the three commands comparable: same rules, same order, same output. + */ +export function runAuthoringRules(command: AuthoringCommand, run: AuthoringRuleRun): AuthoringFinding[] { + const findings: AuthoringFinding[] = []; + const ctx: AuthoringRuleContext = { sduiManifest: run.sduiManifest }; + for (const rule of authoringRulesFor(command)) { + const stack = rule.input === 'normalized' ? run.normalized : (run.parsed ?? run.normalized); + findings.push(...rule.run(stack, ctx)); + } + return findings; +} + +/** Split findings into the gating set and the advisory set (`warning` + `info`). */ +export function splitBySeverity(findings: readonly AuthoringFinding[]): { + errors: AuthoringFinding[]; + advisories: AuthoringFinding[]; +} { + return { + errors: findings.filter((f) => f.severity === 'error'), + advisories: findings.filter((f) => f.severity !== 'error'), + }; +} diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index 8e5019b322..3d9ba98b61 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -24,11 +24,27 @@ import { resolveStorageCapabilityArg } from '../commands/serve.js'; * where the server would. */ export async function buildDataMigrationPlugins( - opts: { storage?: boolean } = {}, + opts: { storage?: boolean; automation?: boolean } = {}, ): Promise { const plugins: unknown[] = []; const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); plugins.push(new PlatformObjectsPlugin()); + if (opts.automation === true) { + // `os migrate meta --stored` needs the automation ENGINE, never the + // automation RUNTIME (#4454). Flow-node conversions carry ADR-0078's + // open-namespace conflict guard, which consults the live executor registry + // to tell a rename from a clobber — and only this plugin has that registry. + // + // `armRuntime: false` is what makes taking it safe: the engine and the full + // node registry come up (built-ins plus whatever `automation:ready` + // contributes, because a PARTIAL registry would make the guard rewrite over + // a live custom node type instead of refusing), and then nothing is armed — + // no flow registered, no record trigger or scheduled job bound, no + // declarative connector materialized, no suspended run resumed. A migration + // process must not become a second server. + const { AutomationServicePlugin } = await import('@objectstack/service-automation'); + plugins.push(new AutomationServicePlugin({ armRuntime: false, suspendedRunStore: 'memory' })); + } if (opts.storage === true) { try { const { SettingsServicePlugin } = await import('@objectstack/service-settings'); diff --git a/packages/cli/src/utils/lint-flow-patterns.test.ts b/packages/cli/src/utils/lint-flow-patterns.test.ts index f7ac8c10fa..3e1f8076a6 100644 --- a/packages/cli/src/utils/lint-flow-patterns.test.ts +++ b/packages/cli/src/utils/lint-flow-patterns.test.ts @@ -13,6 +13,11 @@ import { FLOW_APPROVAL_REVISE_DISABLED, FLOW_RUNAS_UNSCOPED, FLOW_ERROR_LABEL_NOT_FAULT, + FLOW_BRANCH_LABEL_UNMATCHED, + FLOW_DECISION_UNCONDITIONAL_BRANCH, + FLOW_DEFAULT_EDGE_WITH_CONDITION, + FLOW_MULTIPLE_DEFAULT_EDGES, + FLOW_INERT_NODE_CONDITION, } from './lint-flow-patterns.js'; const CEL = (source: string) => ({ dialect: 'cel', source }); @@ -185,10 +190,16 @@ describe('lintFlowPatterns — wrong interpolation syntax (#1315)', () => { expect(rules(nodeFlow({ objectName: 'm', fields: { price: '$5.00', label: 'Total $5' } }))).toEqual([]); }); it('a CEL condition (skipped — not a template value)', () => { - expect(rules({ flows: [{ name: 'd', nodes: [ + // Scoped to the #1315 interpolation rules on purpose: this shape DOES + // trip `flow-inert-node-condition` (#4414 — a decision never reads + // `config.condition`), a different finding about a different defect, + // which must not make this case read as a brace mistake. + const found = rules({ flows: [{ name: 'd', nodes: [ { id: 'start', type: 'start', config: {} }, { id: 'dec', type: 'decision', config: { condition: 'record.amount > 100' } }, - ], edges: [] }] })).toEqual([]); + ], edges: [] }] }); + expect(found).not.toContain(FLOW_DOUBLE_BRACE_INTERP); + expect(found).not.toContain(FLOW_BARE_DOLLAR_REF); }); }); }); @@ -448,3 +459,230 @@ describe('flow-error-label-not-fault (#3863)', () => { }, ); }); + +/** + * #4414 — a decision that declares a branch it cannot route. + * + * `guardFlow` is `examples/app-crm/src/flows/convert-lead.flow.ts`'s guard, + * reduced: one CEL-guarded abort branch and one fallback branch. + */ +function guardFlow(opts: { + conditions?: Array<{ label: string; expression: string }>; + proceed?: Record; + extra?: Array>; +} = {}) { + return { + flows: [{ + name: 'convert_lead', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { + id: 'check', type: 'decision', + ...(opts.conditions ? { config: { conditions: opts.conditions } } : {}), + }, + { id: 'abort', type: 'screen', config: {} }, + { id: 'proceed', type: 'screen', config: {} }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e_yes', source: 'check', target: 'abort', label: 'Yes', condition: "lead.status == 'converted'" }, + { id: 'e_no', source: 'check', target: 'proceed', label: 'No', ...(opts.proceed ?? {}) }, + ...(opts.extra ?? []), + ], + }], + }; +} + +describe('flow-branch-label-unmatched (#4414)', () => { + // The shipped shape: labels `'Yes — already converted'` / `'No — proceed'` + // against out-edges labelled `'Yes'` / `'No'` — zero matches. + it('flags decision branch labels no out-edge carries', () => { + const fnds = lintFlowPatterns(guardFlow({ + proceed: { isDefault: true }, + conditions: [ + { label: 'Yes — already converted', expression: "lead.status == 'converted'" }, + { label: 'No — proceed', expression: 'true' }, + ], + })).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED); + + expect(fnds).toHaveLength(1); + // Gating: a label nothing claims cannot route under any reading (#4414). + expect(fnds[0].severity).toBe('error'); + expect(fnds[0].where).toContain("decision 'check'"); + expect(fnds[0].message).toContain("'yes — already converted'"); + expect(fnds[0].message).toContain("'no — proceed'"); + // The consequence, not just the mismatch. + expect(fnds[0].message).toContain('EVERY'); + }); + + it('flags a partial mismatch — one claimed label does not excuse the other', () => { + const fnds = lintFlowPatterns(guardFlow({ + proceed: { isDefault: true }, + conditions: [ + { label: 'Yes', expression: "lead.status == 'converted'" }, + { label: 'No — proceed', expression: 'true' }, + ], + })).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED); + expect(fnds).toHaveLength(1); + // Only the unclaimed label is reported as unclaimed; `'yes'` still shows up + // later in the message as one of the out-edge labels that DO exist. + expect(fnds[0].message).toMatch(/declares branch label\(s\) 'no — proceed' that no out-edge/); + }); + + it('does NOT flag labels that match (case/whitespace-insensitively)', () => { + expect(lintFlowPatterns(guardFlow({ + proceed: { isDefault: true }, + conditions: [{ label: ' yes ', expression: 'true' }], + })).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED)).toHaveLength(0); + }); + + it('does NOT flag a decision that declares no conditions at all', () => { + expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0); + }); +}); + +describe('flow-decision-unconditional-branch (#4414)', () => { + // The actual hole: `e_no` has no condition and no `isDefault`, so it is + // traversed on every pass — the abort screen AND the wizard behind it. + it('flags an unconditional out-edge alongside a guarded one', () => { + const fnds = lintFlowPatterns(guardFlow()).filter( + (f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH, + ); + expect(fnds).toHaveLength(1); + // Advisory, deliberately: one guarded + one unconditional out-edge is also + // a legal "maybe notify, always continue" fan-out, so this shape cannot be + // proved wrong the way the two gating rules can. + expect(fnds[0].severity).toBeUndefined(); + expect(fnds[0].message).toContain("'proceed'"); + expect(fnds[0].message).toContain('EVERY pass'); + expect(fnds[0].hint).toContain('isDefault'); + }); + + it('does NOT flag once the fallback is marked isDefault — the fix', () => { + expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0); + }); + + it('does NOT flag once the fallback carries its own condition', () => { + expect(lintFlowPatterns(guardFlow({ + proceed: { condition: "lead.status != 'converted'" }, + }))).toHaveLength(0); + }); + + it('does NOT flag an edge the decision CAN select by declared label', () => { + expect(lintFlowPatterns(guardFlow({ + conditions: [{ label: 'No', expression: 'true' }, { label: 'Yes', expression: 'false' }], + })).filter((f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH)).toHaveLength(0); + }); + + it('does NOT flag a decision with no guarded edge at all — nothing to undercut', () => { + expect(lintFlowPatterns({ + flows: [{ + name: 'plain', + nodes: [{ id: 'start', type: 'start', config: {} }, { id: 'check', type: 'decision' }, { id: 'a', type: 'screen', config: {} }], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'a' }, + ], + }], + })).toHaveLength(0); + }); + + it('does NOT flag a fault edge — error routing is not branch selection', () => { + expect(lintFlowPatterns(guardFlow({ + proceed: { isDefault: true }, + extra: [{ id: 'e_err', source: 'check', target: 'abort', type: 'fault' }], + }))).toHaveLength(0); + }); +}); + +describe('flow-default-edge-with-condition / flow-multiple-default-edges (#4414)', () => { + it('flags an edge that is both the default and conditional', () => { + const fnds = lintFlowPatterns(guardFlow({ + proceed: { isDefault: true, condition: "lead.status != 'converted'" }, + })).filter((f) => f.rule === FLOW_DEFAULT_EDGE_WITH_CONDITION); + expect(fnds).toHaveLength(1); + // Gating: the condition always wins, so the marker never routes (#4414). + expect(fnds[0].severity).toBe('error'); + expect(fnds[0].message).toContain('contradictory'); + }); + + it('flags two default edges out of one node', () => { + const fnds = lintFlowPatterns(guardFlow({ + proceed: { isDefault: true }, + extra: [{ id: 'e_also', source: 'check', target: 'abort', isDefault: true }], + })).filter((f) => f.rule === FLOW_MULTIPLE_DEFAULT_EDGES); + expect(fnds).toHaveLength(1); + // Advisory: two defaults can genuinely mean "when nothing matched, do both". + expect(fnds[0].severity).toBeUndefined(); + expect(fnds[0].where).toContain("node 'check'"); + }); + + it('does NOT flag one default edge per node', () => { + expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0); + }); +}); + +/** + * #4414 — `config.condition` on a node that never reads it. + * + * The key is LIVE on `start` (the trigger gate) and dead on every other + * builtin. `app-todo`'s `check_recurring` carried one for years: a third copy + * of a predicate its out-edges were already enforcing. + */ +function conditionNodeFlow(nodeType: string, config: Record) { + return { + flows: [{ + name: 'cond_flow', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'todo_task', triggerType: 'record-after-update' } }, + { id: 'n', type: nodeType, config }, + ], + edges: [{ id: 'e1', source: 'start', target: 'n' }], + }], + }; +} + +describe('flow-inert-node-condition (#4414)', () => { + it('flags `config.condition` on a decision, pointing at the out-edges', () => { + const fnds = lintFlowPatterns( + conditionNodeFlow('decision', { condition: 'vars.completedTask.is_recurring == true' }), + ).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION); + expect(fnds).toHaveLength(1); + expect(fnds[0].where).toContain("node 'n' (decision)"); + expect(fnds[0].message).toContain('nothing reads it'); + expect(fnds[0].hint).toContain('isDefault'); + // Advisory: the surrounding edges usually still route correctly. + expect(fnds[0].severity).toBeUndefined(); + }); + + it('flags it on a non-decision node too, with the generic hint', () => { + const fnds = lintFlowPatterns( + conditionNodeFlow('update_record', { objectName: 'todo_task', condition: 'a == b' }), + ).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION); + expect(fnds).toHaveLength(1); + expect(fnds[0].hint).toContain("incoming edge's `condition`"); + }); + + it('does NOT flag the start node — that is where the key is read', () => { + expect(lintFlowPatterns({ + flows: [{ + name: 'gated', + runAs: 'system', + nodes: [{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *', condition: 'record.active == true' } }], + edges: [], + }], + }).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION)).toHaveLength(0); + }); + + it('does NOT flag a node with no condition, or an empty one', () => { + expect(lintFlowPatterns(conditionNodeFlow('decision', {}))).toHaveLength(0); + expect(lintFlowPatterns(conditionNodeFlow('decision', { condition: ' ' }))).toHaveLength(0); + }); + + it('does NOT flag a PLUGIN node type — its executor may legitimately read it', () => { + // ADR-0018 keeps `node.type` open; we can only prove the key inert for the + // builtins we ship. + expect(lintFlowPatterns(conditionNodeFlow('acme_custom_step', { condition: 'a == b' }))).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/utils/lint-flow-patterns.ts b/packages/cli/src/utils/lint-flow-patterns.ts index e3925fc2ce..3715c7ee13 100644 --- a/packages/cli/src/utils/lint-flow-patterns.ts +++ b/packages/cli/src/utils/lint-flow-patterns.ts @@ -7,11 +7,30 @@ * generating templates) toward the robust pattern without failing the build on * a technically-legal construct. * - * A finding carrying `severity: 'error'` FAILS the build. That is reserved for - * shapes that are a *guaranteed* runtime failure rather than a risk — currently - * only {@link FLOW_RUNAS_UNSCOPED}, where the runtime refuses the data - * operation outright (#3760), so warning about it would just be a slower way of - * finding out. + * A finding carrying `severity: 'error'` FAILS the build. The bar is: **no + * reading of the author's metadata does what it says, deterministically, on + * every run.** Warning about such a shape is just a slower way of finding out. + * That covers two kinds, and only these: + * + * - **The runtime refuses.** {@link FLOW_RUNAS_UNSCOPED} — a user-less trigger + * with `runAs:'user'` has no identity to scope to, so the data operation is + * refused outright (#3760). + * - **The declaration is inert and the route silently differs from what is + * written.** {@link FLOW_BRANCH_LABEL_UNMATCHED} — a decision computes a + * branch no out-edge carries, so the branch is discarded and every out-edge + * is considered instead. {@link FLOW_DEFAULT_EDGE_WITH_CONDITION} — an edge + * that is both the default and conditional; the condition wins and the + * marker routes nothing. Neither *fails*; both are wrong every time, and + * silently, which is worse (#4414). + * + * The bar is deliberately about *provability*, not severity of consequence. A + * shape with a legitimate reading stays a warning even when it is usually a + * mistake — {@link FLOW_DECISION_UNCONDITIONAL_BRANCH} is normally a guard that + * does not guard, but a decision with one guarded and one unconditional out-edge + * is a legal "maybe notify, always continue" fan-out, and + * {@link FLOW_MULTIPLE_DEFAULT_EDGES} can genuinely mean "when nothing matched, + * do both". Failing a customer's build on a shape we cannot prove wrong is a + * worse trade than letting the warning be ignored. * * #1874 — time-relative rules via record-change date-EQUALITY. A start-node * trigger condition like `end_date == daysFromNow(60)` on a `record-*` trigger @@ -78,6 +97,36 @@ export const FLOW_APPROVAL_REVISE_DISABLED = 'flow-approval-revise-disabled'; */ export const FLOW_RUNAS_UNSCOPED = 'flow-runas-unscoped'; export const FLOW_ERROR_LABEL_NOT_FAULT = 'flow-error-label-not-fault'; +/** #4414 — the four ways a decision's declared branching fails to route. */ +export const FLOW_BRANCH_LABEL_UNMATCHED = 'flow-branch-label-unmatched'; +export const FLOW_DECISION_UNCONDITIONAL_BRANCH = 'flow-decision-unconditional-branch'; +export const FLOW_DEFAULT_EDGE_WITH_CONDITION = 'flow-default-edge-with-condition'; +export const FLOW_MULTIPLE_DEFAULT_EDGES = 'flow-multiple-default-edges'; +/** #4414 — `config.condition` on a node whose executor never reads it. */ +export const FLOW_INERT_NODE_CONDITION = 'flow-inert-node-condition'; + +/** + * Node types that ship in the box. `config.condition` is only ever READ on the + * `start` node (the trigger gate — `AutomationEngine.execute` and the trigger + * bindings); every other builtin ignores it, so a predicate written there is a + * guard that does not guard. + * + * Deliberately a closed list rather than "any node type": ADR-0018 keeps + * `node.type` open so plugins can register their own, and a plugin executor is + * free to declare and read `config.condition` from its own `configSchema`. We + * can only prove the key is inert for the types we ship. + * + * Kept as a literal rather than imported from `FLOW_BUILTIN_NODE_TYPES` because + * membership here means "we have read this executor and it ignores the key", + * which is a stronger claim than "this id is built in" — a new builtin must be + * checked, not silently inherited. + */ +const INERT_CONDITION_NODE_TYPES = new Set([ + 'decision', 'assignment', 'loop', 'parallel', 'try_catch', + 'create_record', 'update_record', 'delete_record', 'get_record', + 'http', 'notify', 'script', 'screen', 'wait', 'subflow', 'map', + 'connector_action', 'approval', 'end', +]); /** Node types that perform a data operation — the ones `flow.runAs` governs (#1888). */ const DATA_NODE_TYPES = new Set(['get_record', 'create_record', 'update_record', 'delete_record']); @@ -291,6 +340,198 @@ function scanErrorLabelledEdges( } } +/** + * #4414 — a decision node that DECLARES a branch it cannot route. + * + * A decision has three declared ways to pick a branch, and until #4414 only one + * of them worked. They now compose (`branchLabel` narrows the edge set → + * `condition` gates → `isDefault` catches the rest), but composing them still + * leaves four authorable shapes where what the author wrote does not route what + * they meant. All four are silent at run time — the flow completes green, having + * taken the wrong path — so they are caught here, at authoring time: + * + * (1) `flow-branch-label-unmatched` — the decision's `conditions[].label` names + * a branch no out-edge carries. Traversal cannot honour a label nothing + * claims, so it falls back to considering EVERY out-edge. This is the + * shipped defect: app-crm's convert-lead guard computed `'No — proceed'` + * against out-edges labelled `'Yes'` / `'No'`, matched nothing, and ran + * both branches. + * (2) `flow-decision-unconditional-branch` — an out-edge of a decision that has + * no `condition`, no `isDefault`, and no label the decision can select. It + * is traversed on EVERY pass, in parallel with whichever branch did match, + * so the guard next to it does not guard. + * (3) `flow-default-edge-with-condition` — `isDefault` means "when nothing else + * matched"; a condition on the same edge contradicts it (BPMN forbids a + * conditional default flow). The condition wins and the marker is inert. + * (4) `flow-multiple-default-edges` — two fallbacks out of one node. Both are + * traversed when nothing matched, which is a parallel fan-out, not the + * exclusive "otherwise" the marker promises. + * (5) `flow-inert-node-condition` — `config.condition` on a node that never + * reads it. The key is the trigger gate on `start` and dead on every other + * builtin, so the predicate reads like a guard and gates nothing. + * + * (1) and (3) GATE — neither has a reading under which the author's metadata + * routes what it says, on any run, so a warning would just be a slower way of + * finding out. (2) and (4) stay advisory: an unconditional sibling is a legal + * "maybe notify, always continue" fan-out, and two defaults can mean "when + * nothing matched, do both". See the severity policy at the top of this file. + * + * The engine also warns when it hits (1) live — a stored flow authored before + * this rule existed still reaches run time. + */ +function scanBranchRouting( + flowName: string, + nodes: AnyRec[], + edges: AnyRec[], + findings: FlowLintFinding[], +): void { + const outEdgesBySource = new Map(); + for (const e of edges) { + if (e.type === 'fault') continue; // error routing, not branch selection + const src = typeof e.source === 'string' ? e.source : ''; + if (!src) continue; + if (!outEdgesBySource.has(src)) outEdgesBySource.set(src, []); + outEdgesBySource.get(src)!.push(e); + } + + // (3) + (4) apply to every node's out-edges, not just decisions — `isDefault` + // is meaningful wherever conditional siblings exist. + for (const [src, outs] of outEdgesBySource) { + for (const e of outs) { + if (e.isDefault === true && e.condition) { + findings.push({ + where: `flow '${flowName}' · edge '${src}' → '${String(e.target)}'`, + message: + `edge sets \`isDefault: true\` AND a \`condition\` — contradictory. \`isDefault\` means ` + + `"take this edge when NO sibling condition matched"; a condition makes it an ordinary ` + + `guarded branch. The condition wins and the default marker routes nothing.`, + hint: + `Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` ` + + `for the "otherwise" path. (#4414)`, + rule: FLOW_DEFAULT_EDGE_WITH_CONDITION, + // Gating: the two keys contradict, the condition always wins, and the + // marker never routes. No reading makes it do what it says. + severity: 'error', + }); + } + } + const defaults = outs.filter((e) => e.isDefault === true && !e.condition); + if (defaults.length > 1) { + findings.push({ + where: `flow '${flowName}' · node '${src}'`, + message: + `${defaults.length} out-edges are marked \`isDefault: true\` (${defaults + .map((e) => `'${String(e.target)}'`) + .join(', ')}) — a node has at most ONE default path. All of them are traversed together ` + + `when no condition matches, which is a parallel fan-out, not an "otherwise".`, + hint: + `Keep \`isDefault: true\` on the single fallback edge and give the others a \`condition\` ` + + `(or leave them unconditional if the fan-out really is intended). (#4414)`, + rule: FLOW_MULTIPLE_DEFAULT_EDGES, + }); + } + } + + // (5) #4414 — `config.condition` on a node that never reads it. + // + // The key is LIVE on `start`, where it is the trigger gate, and dead + // everywhere else: the engine parse-validates it on every node at + // registration (so a malformed one is caught), and then no executor but the + // start path looks at it. On a `decision` the name makes it read as the + // branch predicate — app-todo's `check_recurring` carried one for exactly + // that reason, a third copy of a predicate its out-edges were already + // enforcing. Where the out-edges are NOT already deciding, the same shape is + // a guard that does nothing and every out-edge runs. + // + // Advisory: the surrounding edges usually still route correctly, so this is + // dead weight rather than a provable misroute (the gating bar is at the top + // of this file). + for (const node of nodes) { + const nodeType = typeof node.type === 'string' ? node.type : ''; + if (!INERT_CONDITION_NODE_TYPES.has(nodeType)) continue; + const cfg = (node.config ?? {}) as AnyRec; + if (cfg.condition == null || conditionSource(cfg.condition).trim() === '') continue; + findings.push({ + where: `flow '${flowName}' · node '${String(node.id)}' (${nodeType})`, + message: + `\`config.condition\` is set but nothing reads it — the key is the trigger gate on a \`start\` ` + + `node and is ignored on every other node type, so this predicate never gates anything. ` + + `(It is still parse-validated at registration, which is why a malformed one is caught and an ` + + `inert one is not.)`, + hint: + nodeType === 'decision' + ? `Branching lives on the OUT-EDGES: give each branch its own \`condition\` and mark the ` + + `fallback \`isDefault: true\`. If the edges already carry the predicate, delete this copy. (#4414)` + : `Delete it, or move the predicate to the incoming edge's \`condition\` if this step was ` + + `meant to be conditional. (#4414)`, + rule: FLOW_INERT_NODE_CONDITION, + }); + } + + // (1) + (2) are about a DECISION's own declared branching. + for (const node of nodes) { + if (node.type !== 'decision') continue; + const nid = typeof node.id === 'string' ? node.id : ''; + if (!nid) continue; + const outs = outEdgesBySource.get(nid) ?? []; + if (outs.length === 0) continue; + + const cfg = (node.config ?? {}) as AnyRec; + const declaredLabels = new Set( + (Array.isArray(cfg.conditions) ? (cfg.conditions as AnyRec[]) : []) + .map((c) => (typeof c?.label === 'string' ? c.label.trim().toLowerCase() : '')) + .filter(Boolean), + ); + const edgeLabels = new Set(outs.map(edgeLabelOf).filter(Boolean)); + + // (1) a declared branch label nothing claims. `default` is the engine's own + // sentinel for "no declared condition matched" and is additionally + // claimed by the BPMN default edge, so it is never counted as unclaimed. + const unclaimed = [...declaredLabels].filter((l) => !edgeLabels.has(l)); + if (unclaimed.length > 0) { + findings.push({ + where: `flow '${flowName}' · decision '${nid}'`, + message: + `declares branch label(s) ${unclaimed.map((l) => `'${l}'`).join(', ')} that no out-edge ` + + `carries — out-edge labels are [${[...edgeLabels].map((l) => `'${l}'`).join(', ') || 'none'}]. ` + + `Traversal cannot honour a label nothing claims, so it falls back to considering EVERY ` + + `out-edge and the branch the decision computed is ignored.`, + hint: + `Make an out-edge's \`label\` match the declared branch exactly, or drop \`config.conditions\` ` + + `and branch on the edges instead (\`condition\` per branch + \`isDefault: true\` on the ` + + `fallback) — one mechanism per decision, never both. (#4414)`, + rule: FLOW_BRANCH_LABEL_UNMATCHED, + // Gating: a label nothing claims cannot route under ANY reading, on + // every run. See the severity policy at the top of this file. + severity: 'error', + }); + } + + // (2) an out-edge nothing can gate: no condition, not the default, and not + // selectable by a label the decision declares. + const gated = outs.filter((e) => e.condition || e.isDefault === true); + if (gated.length === 0) continue; // no branching declared at all — nothing to undercut + const ungated = outs.filter( + (e) => !e.condition && e.isDefault !== true && !declaredLabels.has(edgeLabelOf(e)), + ); + if (ungated.length > 0) { + findings.push({ + where: `flow '${flowName}' · decision '${nid}'`, + message: + `has guarded out-edge(s) alongside unconditional one(s) ` + + `(${ungated.map((e) => `'${String(e.target)}'`).join(', ')}) — an unconditional out-edge is ` + + `traversed on EVERY pass, in parallel with whichever guarded branch matched, so the ` + + `decision does not actually exclude it. A \`label\` alone does not select a path unless the ` + + `decision declares a matching \`conditions[].label\`.`, + hint: + `Mark the fallback \`isDefault: true\` so it is taken only when no sibling condition matched ` + + `(BPMN default flow), or give it its own \`condition\`. (#4414)`, + rule: FLOW_DECISION_UNCONDITIONAL_BRANCH, + }); + } + } +} + function scanApprovalReviseLoops( flowName: string, nodes: AnyRec[], @@ -503,6 +744,11 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { // unconditional out-edge: the handler runs on every SUCCESS, in parallel // with the real path, and never on a failure. scanErrorLabelledEdges(flowName, nodes, edges, findings); + + // (e) #4414 — a decision that declares a branch it cannot route: an + // unclaimable branch label, an unconditional sibling that runs anyway, + // or a self-contradictory / duplicated `isDefault` marker. + scanBranchRouting(flowName, nodes, edges, findings); } return findings; } diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 2500ab101b..4997ccaa25 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -189,4 +189,197 @@ describe('lintLivenessProperties', () => { }); expect(findings).toEqual([]); }); + + // ── datasource (#4487 — the type was ungoverned until the ledger was seeded) ── + // Runs against the REAL datasource.json. These pin the ledger→author loop for + // the type that most needed it: 20 of its 43 props have no runtime consumer, + // and until #4487 nothing told an author so. + + it('warns on the dead datasource blocks — capabilities / healthCheck / retryPolicy (#4487)', () => { + const findings = lintLivenessProperties({ + datasources: [{ + name: 'warehouse', + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + capabilities: { transactions: true, queryAggregations: true }, + healthCheck: { enabled: true, intervalMs: 30000 }, + retryPolicy: { maxRetries: 5, baseDelayMs: 1000 }, + }], + }); + const msgs = paths(findings); + expect(msgs.some((m) => m.includes('capabilities.transactions'))).toBe(true); + expect(msgs.some((m) => m.includes('capabilities.queryAggregations'))).toBe(true); + expect(msgs.some((m) => m.includes('healthCheck.enabled'))).toBe(true); + expect(msgs.some((m) => m.includes('healthCheck.intervalMs'))).toBe(true); + expect(msgs.some((m) => m.includes('retryPolicy.maxRetries'))).toBe(true); + expect(msgs.some((m) => m.includes('retryPolicy.baseDelayMs'))).toBe(true); + }); + + // The entry the whole audit was worth doing for. `capabilities.readOnly` reads + // as a safety switch and gates nothing, and two shipped prescriptions pointed + // authors AT it until #4487. The hint has to name the gate that IS enforced, + // or the warning just relocates the author's confusion. + it('warns on capabilities.readOnly and names the real write gate (#4487)', () => { + const findings = lintLivenessProperties({ + datasources: [{ + name: 'reporting', + driver: 'postgres', + config: { host: 'ro.internal', database: 'reporting' }, + capabilities: { readOnly: true }, + }], + }); + const hit = findings.find((f) => f.message.includes('capabilities.readOnly')); + expect(hit).toBeDefined(); + expect(hit!.hint).toMatch(/allowWrites/); + }); + + it('stays silent on a datasource that only sets live properties (#4487)', () => { + const findings = lintLivenessProperties({ + datasources: [{ + name: 'warehouse', + label: 'Warehouse', + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + pool: { min: 1, max: 10 }, + ssl: { enabled: true, rejectUnauthorized: true }, + active: true, + autoConnect: true, + schemaMode: 'external', + external: { allowWrites: false, allowedSchemas: ['public'] }, + }], + }); + expect(findings).toEqual([]); + }); + + // ── #4488 — the nine remaining types, governed. Pins run against the REAL + // ledgers, one per finding class the audit surfaced. + + // The app ledger's most important entries: area-level gating keys that FAIL + // OPEN (nothing evaluates them, so a "hidden"/"gated" area shows for + // everyone), on the surface whose item-level siblings ARE enforced. + it('warns on the fail-open area gates and dead homePageId (#4488)', () => { + const findings = lintLivenessProperties({ + apps: [{ + name: 'crm', + label: 'CRM', + homePageId: 'nav_pipeline', + areas: [{ + id: 'area_sales', + label: 'Sales', + order: 2, + visible: "'sales' in current_user.positions", + requiredPermissions: ['crm.access'], + navigation: [], + }], + }], + }); + const msgs = paths(findings); + expect(msgs.some((m) => m.includes('homePageId'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.order'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.visible'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(true); + // The gating hints must point at the enforced alternative (per-item gates), + // or the warning just relocates the author's confusion. + const perms = findings.find((f) => f.message.includes('areas.requiredPermissions')); + expect(perms!.hint).toMatch(/per item|Per-item/i); + }); + + // email_template: the WHOLE authoring surface is disconnected from + // sendTemplate (webhook shape) — one per-artifact warn carried on `name`. + it('warns once per email_template artifact via name (#4488)', () => { + const findings = lintLivenessProperties({ + emailTemplates: [{ + name: 'crm.welcome', + label: 'Welcome', + subject: 'Hi {{user.name}}', + bodyHtml: '

Welcome

', + }], + }); + const hit = findings.find((f) => f.message.includes('`name`')); + expect(hit).toBeDefined(); + expect(hit!.hint).toMatch(/sys_email_template/); + }); + + // translation.validationMessages: pointed at by #3778's own migration table, + // read by nothing — the hint must say what actually renders (rule.message). + it('warns on translation.validationMessages (#4488)', () => { + const findings = lintLivenessProperties({ + translations: [{ + name: 'zh_cn', + locale: 'zh-CN', + validationMessages: { discount_limit: '折扣不能超过40%' }, + }], + }); + const hit = findings.find((f) => f.message.includes('validationMessages')); + expect(hit).toBeDefined(); + expect(hit!.hint).toMatch(/message/); + }); + + // book: both inline translations maps are dead (the doc-level map two files + // over works, which is what makes these read alive); job.id and + // mapping.extractQuery are the other flat dead keys. + it('warns on book/job/mapping dead keys (#4488)', () => { + const findings = lintLivenessProperties({ + books: [{ + name: 'crm_guide', + label: 'CRM Guide', + translations: { 'zh-CN': { label: 'CRM 指南' } }, + groups: [{ key: 'basics', label: 'Basics', translations: { 'zh-CN': { label: '基础' } } }], + }], + jobs: [{ + name: 'nightly_sync', + id: 'job_nightly', + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: 'syncAll', + }], + mappings: [{ + name: 'csv_import_contacts', + targetObject: 'contact', + fieldMapping: [], + extractQuery: { object: 'contact', fields: ['name'] }, + }], + }); + const msgs = paths(findings); + expect(msgs.some((m) => m.includes('`translations`'))).toBe(true); + expect(msgs.some((m) => m.includes('groups.translations'))).toBe(true); + expect(msgs.some((m) => m.includes('`id`'))).toBe(true); + expect(msgs.some((m) => m.includes('extractQuery'))).toBe(true); + }); + + // The unwarnable-default rule, negative direction: errorPolicy/batchSize + // (mapping) and includeAll/placement (app selectors) materialize from schema + // defaults on every compiled artifact, so their dead entries carry + // _authorWarnSkipped instead of authorWarn — a compiled stack that only has + // defaults must stay silent. + it('stays silent on schema-default values and live-only artifacts (#4488)', () => { + const findings = lintLivenessProperties({ + mappings: [{ + name: 'api_sync_orders', + targetObject: 'order', + fieldMapping: [{ source: 'Total', target: 'total' }], + mode: 'upsert', + upsertKey: ['external_ref'], + // materialized defaults — must NOT warn: + sourceFormat: 'csv', + errorPolicy: 'skip', + batchSize: 1000, + }], + apps: [{ + name: 'sales', + label: 'Sales', + contextSelectors: [{ + id: 'active_region', + label: 'Region', + optionsSource: { endpoint: '/api/v1/regions', valueKey: 'id', labelKey: 'name' }, + // materialized defaults — must NOT warn: + includeAll: true, + allValue: '', + persist: 'query', + placement: 'sidebar_header', + }], + }], + seeds: [], + }); + expect(findings).toEqual([]); + }); }); diff --git a/packages/cli/src/utils/lint-liveness-properties.ts b/packages/cli/src/utils/lint-liveness-properties.ts index b835b874d2..f6091f263f 100644 --- a/packages/cli/src/utils/lint-liveness-properties.ts +++ b/packages/cli/src/utils/lint-liveness-properties.ts @@ -182,6 +182,21 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ { type: 'page', key: 'pages' }, { type: 'view', key: 'views' }, { type: 'webhook', key: 'webhooks' }, + // #4487. Note what adding a TYPE costs versus adding a warned property: the + // doc below is right that coverage grows by marking entries `authorWarn` — + // but only WITHIN a type already listed here. A newly governed type needs its + // collection registered or its ledger warns nobody, which would leave the + // ledger correct and silent: the exact shape this lint exists to prevent. + { type: 'datasource', key: 'datasources' }, + // #4488 — the six newly governed types that carry `authorWarn` entries. + // (doc / seed / validation are governed too but warn on nothing today, so + // they are not listed; add them here the day one of their entries warns.) + { type: 'app', key: 'apps' }, + { type: 'book', key: 'books' }, + { type: 'job', key: 'jobs' }, + { type: 'email_template', key: 'emailTemplates' }, + { type: 'mapping', key: 'mappings' }, + { type: 'translation', key: 'translations' }, ]; /** diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts new file mode 100644 index 0000000000..865d79ffd6 --- /dev/null +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Resolve the optional ADR-0080 SDUI component manifest. + * + * `validateJsxPages` does full component/prop validation when a manifest is in + * hand and falls back to parse-level checking when it is not. The resolution + * order (project file, then the copy shipped inside `@objectstack/console`) used + * to live inline in `validate.ts` — the only command that ran the JSX gate. Once + * `os build` and `os lint` run it too (#4409), a rule whose STRENGTH depends on + * how its caller resolves an input is a second drift axis waiting to open: the + * same page could pass on one command and fail on another purely because one + * call site forgot the console fallback. One resolver, three callers. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +/** + * The manifest for the current project, or `undefined` when neither source is + * present. Never throws: an unreadable or malformed manifest degrades to + * parse-level JSX validation, which is what the gate did before manifests + * existed. + */ +export function resolveSduiManifest(): unknown { + try { + const projectManifest = join(process.cwd(), 'sdui.manifest.json'); + if (existsSync(projectManifest)) { + const parsed = JSON.parse(readFileSync(projectManifest, 'utf8')); + if (parsed) return parsed; + } + // Fall back to the manifest shipped inside @objectstack/console (built from + // objectui's public-tier registry; the CLI already depends on it). + const consoleManifest = createRequire(import.meta.url).resolve( + '@objectstack/console/dist/sdui.manifest.json', + ); + if (existsSync(consoleManifest)) return JSON.parse(readFileSync(consoleManifest, 'utf8')); + } catch { + /* fall back to parse-level */ + } + return undefined; +} diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts new file mode 100644 index 0000000000..e3c5d83e41 --- /dev/null +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The BEHAVIOURAL half of #4409, next to the structural ratchet in + * `src/commands/authoring-rule-wiring.test.ts`. + * + * The ratchet proves the three authoring commands are WIRED to the same rule + * table. This file proves the wiring produces the same VERDICT: one stack, one + * planted defect, three commands, three rejections. Wiring and verdict are + * genuinely different claims — a command can run a rule and still not gate on + * what it says, which is how #3782's four lints were "wired" into `os build` + * while `os validate` ran and then ignored the same class of finding. + * + * Every case below is a rule the issue MEASURED as running on a strict subset + * of the three, with the command it was blind to named. Each one could emit + * `error`, so each was a gate whose verdict depended on which command CI + * happened to run — nine coin flips. The end-to-end case at the bottom is the + * issue's own repro, run through the real CLI rather than the registry: + * + * os lint EXIT=1 ✗ approval-expression-invalid + * os validate EXIT=0 + * os build EXIT=0 ← the artifact shipped anyway + */ + +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AUTHORING_COMMANDS, runAuthoringRules, type AuthoringCommand } from '../src/lint/authoring-rules.js'; + +const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js'); + +/** A stack that satisfies the security linter, so only the planted defect gates. */ +const withBaseline = (stack: Record) => ({ + manifest: { id: 'parity', namespace: 'parity', version: '1.0.0', name: 'Parity', type: 'app', engines: { protocol: '^17' } }, + ...stack, +}); + +const listView = (object: string) => ({ type: 'grid', label: 'All', columns: ['title'], data: { provider: 'object', object } }); +const formView = (object: string) => ({ type: 'simple', data: { provider: 'object', object }, sections: [] }); + +/** + * One case per rule #4409 measured as partially covered, each planting the + * defect that rule exists to catch. `blindTo` records which command let it + * through before the registry — it is documentation, not an assertion, so the + * table still reads as the issue's matrix once every hole is closed. + */ +const CASES: ReadonlyArray<{ rule: string; blindTo: readonly AuthoringCommand[]; stack: Record }> = [ + // ── P1: gates `os build` did not run, so it PUBLISHED what the others refuse ── + { + rule: 'approval-expression-invalid', + blindTo: ['validate', 'build'], + stack: withBaseline({ + objects: [{ name: 'parity_task', label: 'Task', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }], + flows: [{ + name: 'parity_flow', label: 'Parity', type: 'record_change', runAs: 'system', status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'parity_task', triggerType: 'onCreate' } }, + { id: 'appr', type: 'approval', label: 'Approve', config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'appr', type: 'default' }, + { id: 'e2', source: 'appr', target: 'end', type: 'default' }, + ], + }], + }), + }, + { + rule: 'list-view-filters-in-views-mode', + blindTo: ['build', 'lint'], + stack: withBaseline({ + objects: [{ name: 'parity_task', label: 'Task', sharingModel: 'private', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'tabs' } } }, fields: { name: { type: 'text', label: 'Name' } } }], + }), + }, + { + rule: 'view-container-shape', + blindTo: ['build', 'lint'], + stack: withBaseline({ + views: [{ name: 'all_tasks', label: 'All Tasks', type: 'grid', data: { provider: 'object', object: 'parity_task' }, columns: ['title'] }], + }), + }, + // ── P2: gates `os lint` did not run, so the cheap pre-flight lied ── + { + rule: 'expression-invalid', + blindTo: ['lint'], + stack: withBaseline({ + objects: [{ name: 'parity_lead', label: 'Lead', sharingModel: 'private', fields: { lead_score: { type: 'number', label: 'Score' } }, validations: [{ name: 'r', expression: 'lead_score > 100' }] }], + }), + }, + { + rule: 'filter-token-unknown', + blindTo: ['lint'], + stack: withBaseline({ + dashboards: [{ name: 'exec', label: 'Exec', widgets: [{ id: 'mine', type: 'metric', dataset: 'case_metrics', filter: { owner: '{current_user}', status: 'open' } }] }], + }), + }, + { + rule: 'dashboard-action-target-undefined', + blindTo: ['lint'], + stack: withBaseline({ + dashboards: [{ name: 'exec', label: 'Exec', header: { actions: [{ label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' }] }, widgets: [] }], + }), + }, + { + rule: 'style-node-missing-id', + blindTo: ['lint'], + stack: withBaseline({ + pages: [{ name: 'pricing', regions: [{ name: 'main', components: [{ type: 'flex', responsiveStyles: { large: { padding: 'var(--space-4)' } } }] }] }], + }), + }, + { + rule: 'autonumber-references-unknown-field', + blindTo: ['lint'], + stack: withBaseline({ + objects: [{ name: 'parity_task', label: 'Task', sharingModel: 'private', fields: { task_no: { type: 'autonumber', label: 'No', autonumberFormat: '{plan_no}{000}' } } }], + }), + }, + { + rule: 'view-ref-form-target-kind', + blindTo: ['lint'], + stack: withBaseline({ + views: [{ name: 'parity_task', list: listView('parity_task'), formViews: { default: formView('parity_task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'parity_task.default' }], + }), + }, +]; + +describe('every authoring command reaches the same verdict (#4409)', () => { + it.each(CASES.map((c) => [c.rule, c] as const))('%s gates on all three commands', (_rule, testCase) => { + for (const command of AUTHORING_COMMANDS) { + // `os lint` never Zod-parses, so it hands the normalized stack to both + // tiers — modelled here exactly as the command does it. + const findings = runAuthoringRules(command, { + normalized: testCase.stack, + ...(command === 'lint' ? {} : { parsed: testCase.stack }), + }); + const gating = findings.filter((f) => f.severity === 'error').map((f) => f.rule); + expect( + gating, + `os ${command} does not gate on ${testCase.rule} — it did not before #4409 either ` + + `(blind: ${testCase.blindTo.join(', ')}), which is the drift this registry exists to end.`, + ).toContain(testCase.rule); + } + }); + + it('the case table still covers every command as a blind spot', () => { + // Guard the guard: if the table drifted to cover only one direction, the + // suite above would keep passing while testing half the problem. The issue + // found holes in BOTH — `os build` publishing what `os lint` refuses, and + // `os lint` passing what `os build` rejects. + const blind = new Set(CASES.flatMap((c) => c.blindTo)); + expect([...blind].sort()).toEqual(['build', 'lint', 'validate']); + }); + + /** + * The issue's own repro, end to end through the real CLI: a flow whose + * expression approver does not parse used to exit 1 on `os lint` and 0 on + * both `os validate` and `os build` — so the build emitted an artifact for a + * flow the runtime refuses to run. + * + * Spawned rather than run in-process: the exit CODE is the contract CI reads, + * and only a real process produces it. + */ + it('the broken approval flow now exits non-zero on all three commands', () => { + const dir = mkdtempSync(join(tmpdir(), 'os-authoring-parity-')); + try { + // A plain literal config: no imports, so it resolves without a + // node_modules next to it. + writeFileSync( + join(dir, 'objectstack.config.mjs'), + `export default ${JSON.stringify(CASES[0].stack, null, 2)};\n`, + ); + + for (const command of ['lint', 'validate', 'build']) { + let exitCode = 0; + let output = ''; + try { + output = execFileSync(process.execPath, [cliBin, command], { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + } catch (error: any) { + exitCode = error.status ?? 1; + output = `${error.stdout ?? ''}${error.stderr ?? ''}`; + } + expect(exitCode, `os ${command} exited 0 on a flow whose approver expression does not parse`).toBe(1); + expect(output, `os ${command} rejected the stack without naming the rule`).toContain( + 'approval-expression-invalid', + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 1f35a17c7c..caa3b532d5 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -7,75 +7,129 @@ import { join } from 'node:path'; /** * `os validate` is documented — and relied on by CI setups — as the READ-ONLY * SUPERSET of the gates `os build` runs: same checks, no artifact emitted. That - * contract has no enforcement, so it drifted (#3782): four authoring lints - * (`lintFlowPatterns`, `lintLivenessProperties`, `lintAutonumberFormats`, - * `lintViewRefs`) were wired into `compile.ts` only. Two of them already emitted - * `severity: 'error'`, so `os validate` reported a clean stack that `os build` - * then rejected — the precise failure the contract exists to prevent. + * contract had no enforcement, so it drifted (#3782): four authoring lints were + * wired into `compile.ts` only, two of them already emitting `severity: 'error'`, + * so `os validate` reported a clean stack that `os build` then rejected. * - * The drift was invisible because every OTHER gate is a `@objectstack/lint` - * import shared by both files, while these four are CLI-local `../utils/lint-*` - * modules that only `compile.ts` ever imported. + * ## What changed, and what this file still guards * - * This is a source-level gate rather than a behavioural one on purpose: it fails - * when a gate is ADDED to the build without being added to validate, which is - * the moment the mistake is cheap to fix — not later, when some app trips it. + * The metadata rules the two commands share now come from ONE table + * (`src/lint/authoring-rules.ts`, #4409), and its own ratchet — + * `src/commands/authoring-rule-wiring.test.ts` — proves all three authoring + * commands run the identical gating set. That is a stronger guarantee than the + * source diff this file used to do, and it covers `os lint` too. + * + * What the registry CANNOT cover is the gates that are not pure functions of the + * stack: the capability-provider preflight reads `node_modules`, the docs lint + * reads `src/docs/`, the access-matrix snapshot reads a file next to the config. + * Those are still hand-wired per command, so they can still drift — and one of + * them already had. `collectAndLintDocs` gated `os build` and never ran on + * `os validate`, invisible for the same reason the #3782 four were: the old + * scan keyed on the `lint*`/`validate*` naming convention, and this gate is + * named `collect*`. This file now names each shared gate explicitly instead of + * pattern-matching for them. + * + * Source-level rather than behavioural on purpose: it fails when a gate is ADDED + * to the build without being added to validate, which is the moment the mistake + * is cheap to fix — not later, when some app trips it. */ const COMMANDS_DIR = join(__dirname, '..', 'src', 'commands'); +/** + * Gates that are NOT registry rules (they need the filesystem or the emitted + * artifact) and that both commands must therefore wire by hand. + * + * Adding a gate to `compile.ts` means adding it here and to `validate.ts`, or + * to `BUILD_ONLY_GATES` below with a reason. There is no third option — that is + * the whole point of the file. + */ +const SHARED_NON_REGISTRY_GATES: readonly string[] = [ + // [#3366] Resolves each `requires` token's provider in the active edition. + 'preflightRequiredCapabilities', + // [#3786] The pre-parse undeclared-key diff, both halves. + 'lintUnknownStackKeys', + 'lintUnknownAuthoringKeys', + // [ADR-0046] Package docs: flatness, prefixed names, MDX/image ban, links. + 'collectAndLintDocs', +]; + /** * Gates `os build` may legitimately run that `os validate` does not. * - * Adding an entry here is a deliberate assertion that the check CANNOT be made - * read-only (it needs the emitted artifact, the bundler, the filesystem output). - * A gate that merely *reads* the parsed stack does not belong here — wire it - * into `validate.ts` instead. Empty today, and that is the healthy state. + * Each entry is a deliberate assertion that the check CANNOT be made read-only + * — it needs the emitted artifact, the bundler, or filesystem output. A gate + * that merely *reads* the parsed stack does not belong here; wire it into + * `validate.ts`, or better, register it in `src/lint/authoring-rules.ts` so all + * three authoring commands get it at once. */ -const BUILD_ONLY_GATES: readonly string[] = []; +const BUILD_ONLY_GATES: Readonly> = { + buildAccessMatrix: + '[ADR-0090 D6] The snapshot gate reads (and with --update-access-matrix WRITES) access-matrix.json ' + + 'next to the config. Rewriting a committed snapshot is not a read-only operation.', + diffAccessMatrix: 'The comparison half of the same D6 snapshot gate.', + lowerCallables: + 'Lowers inline `function` handlers to string refs so they survive JSON.stringify. It exists to ' + + 'produce the artifact; there is nothing to lower when nothing is emitted.', + buildRuntimeBundle: 'Emits the objectstack-runtime.{hash}.mjs sibling module. Artifact output by definition.', +}; + +const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8'); /** Every `lintFoo(`/`validateFoo(` call site in a command's source. */ function gateCallsIn(file: string): Set { - const src = readFileSync(join(COMMANDS_DIR, file), 'utf8'); - const calls = src.match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? []; + const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? []; return new Set(calls); } -describe('os validate is the read-only superset of os build (#3782)', () => { - it('runs every gate compile.ts runs', () => { +/** Is `name` invoked anywhere in this command's source? */ +const calls = (file: string, name: string) => new RegExp(String.raw`\b${name}\s*\(`).test(sourceOf(file)); + +describe('os validate is the read-only superset of os build (#3782, #4409)', () => { + it('both commands run the shared authoring-rule registry', () => { + for (const file of ['compile.ts', 'validate.ts']) { + expect(calls(file, 'runAuthoringRules'), `${file} must run the authoring-rule registry`).toBe(true); + } + }); + + it.each(SHARED_NON_REGISTRY_GATES)('both commands run %s', (gate) => { + // Guard the guard: a gate that has been renamed or deleted must fail here + // rather than pass vacuously on both sides. + expect(calls('compile.ts', gate), `compile.ts no longer calls ${gate} — is this list stale?`).toBe(true); + expect( + calls('validate.ts', gate), + `os build runs ${gate} and os validate does not, so a stack can pass 'os validate' and fail ` + + `'os build'. Wire it into packages/cli/src/commands/validate.ts (mirroring compile.ts's severity ` + + `handling), or — only if it genuinely cannot run without emitting an artifact — move it to ` + + `BUILD_ONLY_GATES in this file with a reason.`, + ).toBe(true); + }); + + it('compile.ts hand-wires no gate validate.ts is missing', () => { const compileGates = gateCallsIn('compile.ts'); const validateGates = gateCallsIn('validate.ts'); - // Guard the guard: if the extraction regex silently stops matching, the - // set-difference below passes vacuously and the gate goes quietly dead. - expect(compileGates.size).toBeGreaterThan(10); + // Non-vacuity: the extraction must still find the pre-parse key lints. + expect(compileGates.size).toBeGreaterThan(0); const missing = [...compileGates] .filter((g) => !validateGates.has(g)) - .filter((g) => !BUILD_ONLY_GATES.includes(g)) + .filter((g) => !(g in BUILD_ONLY_GATES)) .sort(); expect( missing, - `os build runs ${missing.length} gate(s) that os validate does not, so a stack ` + - `can pass 'os validate' and fail 'os build': ${missing.join(', ')}.\n` + - `Wire each into packages/cli/src/commands/validate.ts (mirroring the severity ` + - `handling in compile.ts), or — only if it genuinely cannot run without emitting ` + - `an artifact — add it to BUILD_ONLY_GATES in this file with a reason.`, + `os build runs ${missing.length} gate(s) that os validate does not: ${missing.join(', ')}.\n` + + `Register it in packages/cli/src/lint/authoring-rules.ts so all three authoring commands run ` + + `it, wire it into validate.ts by hand and add it to SHARED_NON_REGISTRY_GATES, or add it to ` + + `BUILD_ONLY_GATES with a reason.`, ).toEqual([]); }); - it('runs the four CLI-local authoring lints that regressed in #3782', () => { - const validateGates = gateCallsIn('validate.ts'); - - for (const gate of [ - 'lintFlowPatterns', - 'lintLivenessProperties', - 'lintAutonumberFormats', - 'lintViewRefs', - ]) { - expect(validateGates.has(gate), `validate.ts must call ${gate}`).toBe(true); - } + it('every BUILD_ONLY_GATES entry is still called by the build', () => { + // A ratchet nobody prunes rots into a permission slip. + const stale = Object.keys(BUILD_ONLY_GATES).filter((g) => !calls('compile.ts', g)); + expect(stale, `BUILD_ONLY_GATES entries compile.ts no longer calls: ${stale.join(', ')}`).toEqual([]); }); /** @@ -92,7 +146,7 @@ describe('os validate is the read-only superset of os build (#3782)', () => { */ it('both commands pass a conversion-notice sink to normalizeStackInput', () => { for (const file of ['compile.ts', 'validate.ts']) { - const src = readFileSync(join(COMMANDS_DIR, file), 'utf8'); + const src = sourceOf(file); const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/); expect(call, `${file} must call normalizeStackInput`).not.toBeNull(); expect( diff --git a/packages/client/README.md b/packages/client/README.md index 76cd9c7946..400abc4bad 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -134,7 +134,10 @@ Batch operations support the following options: - `atomic`: If true, rollback entire batch on any failure (default: true). - `returnRecords`: If true, return full record data in response (default: false). - `continueOnError`: If true (and atomic=false), continue processing remaining records after errors. -- `validateOnly`: If true, validate records without persisting changes (dry-run mode). + +> `validateOnly` was retired in #4052 — it was never implemented and batch surfaces persisted +> regardless, so there is no batch dry-run today. Drop the key; see +> `docs/protocol-upgrade-guide.md` (`batch-options-validate-only-retired`). ### Error Handling The client provides standardized error handling with machine-readable error codes: diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 29b139e26a..579a8ed4e3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -653,6 +653,32 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * ADR-0087: rewrite stored `sys_metadata` rows into today's canonical + * shape — the server-side form of `os migrate meta --stored` (#4327), + * for operators who cannot reach the deployment's database from a shell. + * + * **Preview by default.** Without `apply: true` this reports what it + * would do and writes nothing; the report is the same + * `StoredMigrationReport` the CLI renders (`scanned` / `canonical` / + * `pending` / `rewritten` / `skipped` / `failed`, plus a `rows` list of + * everything that is not already canonical). + * + * Requires the `manage_metadata` capability (403 otherwise) — it rewrites + * every eligible row in the deployment, not one item. + */ + migrateStored: async (opts?: { apply?: boolean; types?: string[] }) => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/_migrate-stored`, { + method: 'POST', + body: JSON.stringify({ + ...(opts?.apply === true ? { apply: true } : {}), + ...(opts?.types && opts.types.length > 0 ? { types: opts.types } : {}), + }), + }); + return this.unwrapResponse(res); + }, + /** * ADR-0020 D3.3 FSM introspection: the legal next states for `field` * from state `from`, per the object's `state_machine` validation rule. @@ -4542,7 +4568,8 @@ export class ObjectStackClient { automation: '/api/v1/automation', packages: '/api/v1/packages', realtime: '/api/v1/realtime', - workflow: '/api/v1/workflow', + // `workflow` removed (#4451, v17): the slot retired with the ApiRoutes + // field — there was never a surface behind the convention. approvals: '/api/v1/approvals', notifications: '/api/v1/notifications', ai: '/api/v1/ai', @@ -4963,10 +4990,8 @@ export type { RealtimeSubscribeRequest, RealtimeSubscribeResponse, GetPresenceResponse, - GetWorkflowConfigResponse, - GetWorkflowStateResponse, - WorkflowTransitionRequest, - WorkflowTransitionResponse, + // Workflow re-exports removed (#4451, v17): the types were deleted from + // @objectstack/spec/api with the retired workflow slot. ListViewsResponse, GetViewResponse, CreateViewResponse, diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 53ecf28b31..d3b666748e 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -80,6 +80,7 @@ export { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, + REACT_BLOCK_NEEDS_RECORD_CONTEXT, } from './validate-react-page-props.js'; export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js'; export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js'; @@ -206,6 +207,9 @@ export type { export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js'; export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js'; +export { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js'; +export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate-action-locations.js'; + export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js'; diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index e31fa0daa1..2f77eb1c86 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -158,12 +158,20 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites }, // The `kind:'react'` page surface. Every prop a react block binds BY FIELD // NAME is resolved against the object it names (#4340) — ``, - // ``, the `record:*` family through the SAME + // ``, `` through the SAME // `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface // over, plus ``'s aggregate/axes (#3701/#3729) and // `searchableFields` (#4329). Squarely the charter's question, on the surface // where it had no answer at all. // + // It also carries `react-block-needs-record-context` (#4413) — a BINDING + // question rather than a resolution one: the `record:*` family reads its + // record from a record page's context, so on THIS surface the binding does + // not exist at all and the props the contract published for it were read by + // no renderer. This rule used to resolve those props' field names against + // the object they named — lint standing guard over a binding that never ran. + // It rejects the blocks now, out of the same parse. + // // It was hand-wired into `os validate` ALONE, so `os lint` and `os compile` // accepted a react page whose every field binding was stale — including the // gating ones (a missing required binding, a filter position naming no field: diff --git a/packages/lint/src/validate-action-locations.test.ts b/packages/lint/src/validate-action-locations.test.ts new file mode 100644 index 0000000000..e140082b8e --- /dev/null +++ b/packages/lint/src/validate-action-locations.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js'; + +/** A stack whose single action declares a real placement. */ +const placed = () => ({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + actions: [ + { + name: 'crm_convert_lead', + label: 'Convert', + type: 'script', + locations: ['record_header'], + }, + ], +}); + +/** The same action with the placement key absent. */ +const unplaced = () => ({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }], +}); + +describe('validateActionLocations', () => { + it('flags an action that declares no locations and that no view places', () => { + const findings = validateActionLocations(unplaced()); + + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(ACTION_NO_PLACEMENT); + expect(findings[0].path).toBe('actions[0]'); + expect(findings[0].where).toBe('action "crm_convert_lead"'); + expect(findings[0].message).toContain('renders on no surface'); + expect(findings[0].hint).toContain('locations: []'); + }); + + it('accepts a declared placement', () => { + expect(validateActionLocations(placed())).toEqual([]); + }); + + it('walks object-embedded actions too', () => { + const findings = validateActionLocations({ + objects: [ + { + name: 'crm_lead', + actions: [{ name: 'crm_score', label: 'Score', type: 'script' }], + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].actions[0]'); + }); + + it('ignores a nameless action — that is action-name-*’s problem, not this rule’s', () => { + expect(validateActionLocations({ actions: [{ label: 'Nameless', type: 'script' }] })).toEqual([]); + }); + + describe('— headless actions (`locations: []`) are never flagged', () => { + it('accepts an explicitly empty placement', () => { + // `content/docs/ui/actions.mdx` documents the empty array as the way to + // declare a REST/MCP/AI-callable action with no UI surface. ADR-0110 D3 + // refuses an UNdeclared handler, so this is the only legal shape for one + // — flagging it would fight that ADR. + const findings = validateActionLocations({ + actions: [{ name: 'crm_sync_remote', label: 'Sync', type: 'script', locations: [] }], + }); + expect(findings).toEqual([]); + }); + + it('distinguishes "nowhere, deliberately" from an unstated placement', () => { + const findings = validateActionLocations({ + actions: [ + { name: 'said_nowhere', type: 'script', locations: [] }, + { name: 'said_nothing', type: 'script' }, + ], + }); + expect(findings.map((f) => f.where)).toEqual(['action "said_nothing"']); + }); + }); + + describe('— a view that places the action by NAME exempts it', () => { + it('exempts an action named in a list view’s bulkActions', () => { + const findings = validateActionLocations({ + ...unplaced(), + views: [{ name: 'crm_lead', list: { bulkActions: ['crm_convert_lead'] } }], + }); + expect(findings).toEqual([]); + }); + + it('exempts an action named in a bulkActionDefs entry (incl. aggregate defs)', () => { + // objectui#3139: an aggregate bulk action has no single-record location + // by construction — the view naming it IS the placement. + const findings = validateActionLocations({ + ...unplaced(), + views: [ + { + name: 'crm_lead', + list: { + bulkActionDefs: [ + { name: 'crm_convert_lead', operation: 'custom', execution: 'aggregate' }, + ], + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('exempts an action named in rowActions', () => { + const findings = validateActionLocations({ + ...unplaced(), + views: [{ name: 'crm_lead', list: { rowActions: ['crm_convert_lead'] } }], + }); + expect(findings).toEqual([]); + }); + + it('exempts via a named listViews entry, not just the default list', () => { + const findings = validateActionLocations({ + ...unplaced(), + views: [{ name: 'crm_lead', listViews: { hot: { bulkActions: ['crm_convert_lead'] } } }], + }); + expect(findings).toEqual([]); + }); + + it('exempts via an OBJECT-embedded list view — an object has no top-level `list`', () => { + const findings = validateActionLocations({ + objects: [ + { + name: 'crm_lead', + listViews: { all: { bulkActions: ['crm_convert_lead'] } }, + }, + ], + actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }], + }); + expect(findings).toEqual([]); + }); + + it('still flags an action no view names, alongside one that is named', () => { + const findings = validateActionLocations({ + actions: [ + { name: 'named_one', type: 'script' }, + { name: 'orphan_one', type: 'script' }, + ], + views: [{ name: 'crm_lead', list: { bulkActions: ['named_one'] } }], + }); + expect(findings.map((f) => f.where)).toEqual(['action "orphan_one"']); + }); + }); + + describe('— floor', () => { + it('returns nothing for a clean stack', () => { + expect(validateActionLocations(placed())).toEqual([]); + }); + + it('returns nothing for an empty stack', () => { + expect(validateActionLocations({})).toEqual([]); + }); + + it('returns nothing for a null stack', () => { + expect(validateActionLocations(null as unknown as Record)).toEqual([]); + }); + }); +}); diff --git a/packages/lint/src/validate-action-locations.ts b/packages/lint/src/validate-action-locations.ts new file mode 100644 index 0000000000..2e6ed2974a --- /dev/null +++ b/packages/lint/src/validate-action-locations.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0078 Phase 3 — Tier-A `action-locations`] An action nobody placed. + * + * `locations` is an action's placement declaration. An action that omits it — + * and that no view names in `bulkActions` / `bulkActionDefs` / `rowActions` — + * has no surface at all: it parses, it publishes, Setup lists it, and no user + * can ever click it. ADR-0078 names this shape in its opening paragraph ("a + * `summary` with no `summaryOperations`; **an `action` with no `locations`**; + * … Each parses, 'renders', reports success — and does nothing") and Phase 3 + * asks for exactly this rule, one verified shape at a time. + * + * The renderer half is now unambiguous: objectui#3142 collapsed four + * disagreeing renderers onto one predicate — an action renders at a location + * only if it DECLARES that location. Before that, `action:bar` and the record + * header showed an undeclared action *everywhere*, which is what made this + * shape look alive; it is measurably inert as of objectui 17.1. + * + * ## What is NOT flagged, and why + * + * **`locations: []` — a deliberate headless action.** `content/docs/ui/ + * actions.mdx` ("Headless actions: declare it, then hide it") documents the + * empty array as a first-class shape: the action stays callable over REST / + * MCP / AI and keeps its capability gate, param contract and audit trail, + * while claiming no UI surface. ADR-0110 D3 refuses an *undeclared* handler, + * so a headless declaration is the only legal way to expose such an action — + * flagging it would fight that ADR. The distinction this rule draws is + * therefore between an author who said "nowhere, deliberately" (`[]`) and one + * who never said anything at all (key absent). + * + * **Actions a view places by NAME.** Naming an action in a list view's + * `bulkActions` or `bulkActionDefs` IS its placement — the selection bar is + * driven by the view, never by `locations` (that is what the retired + * `action.bulkEnabled` tombstone prescribes, and what objectui#3139's + * aggregate bulk mode relies on: an action that only makes sense over a + * selection has no single-record location by construction). `rowActions` is + * exempted on the same zero-false-positive posture (ADR-0072 D1): it is the + * same field pair on the same container, and an author who named an action + * there has stated an intent — a name that resolves to nothing is already + * `action-name-undefined`'s job, not this rule's. + * + * Scope note: this rule asks only "did anyone place this action?". It + * deliberately does NOT check that a declared location is one a renderer + * actually serves, nor that a view's named action belongs to that view's + * object — distinct classes with their own rules. Cross-package placement (a + * view in another installed package naming this action) is the one legitimate + * miss, which is why this is a **warning**: like every other "declared but + * does nothing" finding in this package (`validateSemanticRoles`, + * `lintLivenessProperties`), it is high-signal and never fatal. + */ + +export const ACTION_NO_PLACEMENT = 'action-no-placement'; + +export type ActionLocationsSeverity = 'error' | 'warning'; + +export interface ActionLocationsFinding { + /** Always `warning` — cross-package placement is a legitimate miss. */ + severity: ActionLocationsSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `action "crm_convert_lead"`. */ + where: string; + /** Config path, e.g. `actions[2]` or `objects[0].actions[1]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function strList(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.length > 0) : []; +} + +/** + * Every action name a view places by NAME, across all three list-view tiers: + * `views[i].list`, `views[i].listViews.`, and the object-embedded + * `objects[i].listViews.` (an object has no top-level `list`). Missing + * the object-embedded tier would flag actions that an object's own view + * places — the trap `validate-list-view-mode.ts` already walks around. + */ +function collectNamePlacedActions(stack: AnyRec): Set { + const placed = new Set(); + + const harvest = (container: unknown): void => { + if (!container || typeof container !== 'object') return; + const list = container as AnyRec; + for (const key of ['rowActions', 'bulkActions'] as const) { + for (const n of strList(list[key])) placed.add(n); + } + // A `bulkActionDefs` entry is a loose record; its `name` is the action it + // dispatches. Inline field-patch defs (`operation: 'update'`) carry a name + // that matches no action — harmless here, since an unmatched name simply + // never exempts anything. + for (const def of asArray(list.bulkActionDefs)) { + const n = strName(def?.name); + if (n) placed.add(n); + } + }; + + const harvestListViews = (listViews: unknown): void => { + if (!listViews || typeof listViews !== 'object' || Array.isArray(listViews)) return; + for (const lv of Object.values(listViews as AnyRec)) harvest(lv); + }; + + for (const view of asArray(stack.views)) { + if (!view || typeof view !== 'object') continue; + harvest(view.list); + harvestListViews(view.listViews); + } + for (const obj of asArray(stack.objects)) { + if (!obj || typeof obj !== 'object') continue; + harvestListViews(obj.listViews); + } + + return placed; +} + +/** + * Flag every action that declares no placement and that no view places by + * name. Returns findings (empty = clean). + */ +export function validateActionLocations(stack: AnyRec): ActionLocationsFinding[] { + const findings: ActionLocationsFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const namePlaced = collectNamePlacedActions(stack); + + const check = (action: AnyRec | undefined, path: string): void => { + if (!action || typeof action !== 'object') return; + // `[]` is the documented headless shape — the author said "nowhere" on + // purpose. Only a MISSING key is unstated placement. + if ('locations' in action) return; + const name = strName(action.name); + if (!name) return; // nameless actions are `action-name-*`'s problem + if (namePlaced.has(name)) return; + + findings.push({ + severity: 'warning', + rule: ACTION_NO_PLACEMENT, + where: `action "${name}"`, + path, + message: + `Action "${name}" declares no \`locations\` and no view places it by name, ` + + 'so it renders on no surface — the button exists in metadata and nowhere in the UI.', + hint: + 'Add the surface it belongs on, e.g. `locations: [\'record_header\']` (or `list_item`, ' + + '`list_toolbar`, `record_more`, `record_section`, `record_related`, `global_nav`); or ' + + "place it from a list view's `bulkActions` / `bulkActionDefs` if it acts on a selection. " + + 'If it is meant to be callable over REST / MCP / AI with no UI surface, say so explicitly ' + + 'with `locations: []` — an empty array is the documented headless shape and is never flagged.', + }); + }; + + const actions = asArray(stack.actions); + for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`); + + const objects = asArray(stack.objects); + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!obj || typeof obj !== 'object') continue; + const own = asArray(obj.actions); + for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`); + } + + return findings; +} diff --git a/packages/lint/src/validate-action-name-refs.test.ts b/packages/lint/src/validate-action-name-refs.test.ts index ed54fdb001..88a215d6da 100644 --- a/packages/lint/src/validate-action-name-refs.test.ts +++ b/packages/lint/src/validate-action-name-refs.test.ts @@ -227,6 +227,135 @@ describe('validateActionNameRefs — navigation action items', () => { }); }); +describe('validateActionNameRefs — bulkActionDefs (#4457)', () => { + it('errors on an aggregate def naming nothing', () => { + // `resolveBulkActions` resolves an aggregate def's `name` against the + // object's actions to get the dispatcher it calls once for the selection. + // No match → no dispatcher → the dialog opens and the run reports "has no + // dispatcher wired". Same dead affordance as a bulkActions name. + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { bulkActionDefs: [{ name: 'export_zip', operation: 'custom', execution: 'aggregate' }] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].list.bulkActionDefs[0].name'); + expect(findings[0].where).toContain('bulkActionDefs[0]'); + }); + + it('accepts an aggregate def that resolves', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { bulkActionDefs: [{ name: 'crm_convert_lead', operation: 'custom', execution: 'aggregate' }] }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it("leaves an update/delete def alone — its `name` is a button id, not a reference", () => { + // Resolving `archive` against `stack.actions` would be nonsense: the + // executor writes fields through the data API and never looks the name up. + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { + bulkActionDefs: [ + { name: 'archive', operation: 'update', patch: { archived: true } }, + { name: 'purge', operation: 'delete' }, + ], + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('skips a def carrying an inlined `actionDef` — it brings its own dispatcher', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { + bulkActionDefs: [ + { name: 'export_zip', operation: 'custom', execution: 'aggregate', actionDef: { type: 'api' } }, + ], + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('tells the author no `locations` entry is needed for a selection-bar action', () => { + // The selection bar is the ONE surface that does not filter on + // `locations` — the generic "with the location this surface needs" hint + // would send an author to add a placement that changes nothing. + const findings = validateActionNameRefs({ + ...withActions(), + views: [{ name: 'crm_lead', list: { bulkActions: ['mass_update'] } }], + }); + expect(findings[0].hint).toContain('the selection bar places it by name'); + expect(findings[0].hint).not.toContain('the location this surface needs'); + }); + + it('still says "location" for a row-action menu, which DOES filter', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [{ name: 'crm_lead', list: { rowActions: ['complete_task'] } }], + }); + expect(findings[0].hint).toContain('the location this surface needs'); + }); +}); + +describe('validateActionNameRefs — object-embedded list views (#4457)', () => { + it('walks an object’s own listViews, which have no top-level `list`', () => { + const findings = validateActionNameRefs({ + objects: [ + { + name: 'crm_lead', + listViews: { + all: { + rowActions: ['ghost_row'], + bulkActionDefs: [{ name: 'ghost_zip', operation: 'custom', execution: 'aggregate' }], + }, + }, + }, + ], + actions: [{ name: 'crm_convert_lead', type: 'script' }], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].listViews.all.rowActions[0]', + 'objects[0].listViews.all.bulkActionDefs[0].name', + ]); + expect(findings[0].where).toContain('object "crm_lead"'); + }); + + it('resolves against the object’s OWN actions, not just stack.actions', () => { + const findings = validateActionNameRefs({ + objects: [ + { + name: 'crm_lead', + actions: [{ name: 'crm_score', type: 'script' }], + listViews: { all: { bulkActions: ['crm_score'] } }, + }, + ], + }); + expect(findings).toEqual([]); + }); +}); + describe('validateActionNameRefs — floor', () => { it('is silent on a clean stack and tolerates empty input', () => { expect(validateActionNameRefs(withActions())).toEqual([]); diff --git a/packages/lint/src/validate-action-name-refs.ts b/packages/lint/src/validate-action-name-refs.ts index 69ecf69a07..77605de41c 100644 --- a/packages/lint/src/validate-action-name-refs.ts +++ b/packages/lint/src/validate-action-name-refs.ts @@ -10,8 +10,12 @@ * `z.array(z.string())` / `z.string()`, so a name that matches no defined action * parses and ships: * - * - list views — `rowActions[]` / `bulkActions[]` (both the default `list` - * container and each `listViews.` entry) + * - list views — `rowActions[]` / `bulkActions[]`, plus each + * `bulkActionDefs[]` entry that is a reference rather than a button id + * (`execution: 'aggregate'` — see the walk). Across all three tiers: the + * default `list` container, each `listViews.` entry, and an OBJECT's + * own `listViews.` (added in #4457; an object has no top-level `list`, + * so that tier had simply never been walked) * - page components — `record:quick_actions` → `properties.actionNames[]` * - app navigation — `{ type: 'action', actionDef: { actionName } }` * @@ -133,7 +137,21 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { const known = collectActionNames(stack); - const check = (name: string, where: string, path: string, surface: string) => { + const check = ( + name: string, + where: string, + path: string, + surface: string, + /** + * What the newly-defined action still needs to be reachable from THIS + * surface. A row/quick-action menu filters on `locations`; the selection + * bar does not — naming the action in the view is its whole declaration + * (the `action.bulkEnabled` tombstone says so, and `content/docs/ui/ + * actions.mdx` names it as the one exception to location filtering). One + * hint for both would have to be wrong for one of them. + */ + placement = 'with the location this surface needs', + ) => { if (known.has(name)) return; findings.push({ severity: 'error', @@ -147,44 +165,108 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { suggest(name, known), hint: `Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` + - `with the location this surface needs, remove the reference, or ignore this if the ` + + `${placement}, remove the reference, or ignore this if the ` + `action is contributed by another installed package.` + (known.size > 0 ? ` Defined actions: ${[...known].sort().join(', ')}.` : ''), }); }; - // ── List views: rowActions / bulkActions on `list` + each `listViews.` ── + /** Naming an action in the selection bar IS its placement — see `check`. */ + const SELECTION_BAR_PLACEMENT = + '(no `locations` entry needed — the selection bar places it by name)'; + + /** + * One list container: the default `list`, a `listViews.` entry, or an + * object-embedded one. Shared so the three tiers cannot drift into checking + * different keys — an object has no top-level `list`, and its `listViews` + * went unchecked until #4457 while the view-level ones were covered. + */ + const checkListContainer = ( + container: unknown, + owner: string, + label: string, + path: string, + ) => { + if (!container || typeof container !== 'object') return; + const list = container as AnyRec; + for (const key of ['rowActions', 'bulkActions'] as const) { + const names = strList(list[key]); + for (let ai = 0; ai < names.length; ai++) { + check( + names[ai], + `${owner} · ${label} · ${key}`, + `${path}.${key}[${ai}]`, + key === 'bulkActions' ? 'Bulk-action menu' : 'Row-action menu', + key === 'bulkActions' ? SELECTION_BAR_PLACEMENT : undefined, + ); + } + } + + // `bulkActionDefs` — only SOME entries are name references (#4457). + // + // An `update`/`delete` def is a data-plane mass mutation: its `name` is a + // button id and resolving it against `stack.actions` would be nonsense. + // The one entry that IS a reference is `execution: 'aggregate'`, which is + // exactly what objectui's `resolveBulkActions` looks up by name to attach + // the action it dispatches — a name that hits nothing leaves the def with + // no dispatcher, so the button opens its dialog and the run resolves to + // "no dispatcher wired". Same dead affordance, same severity. + // + // (Spec's `BulkActionDefSchema` rejects a hand-written `actionDef`, but a + // stack can reach lint through paths that never parsed — a raw JSON fixture, + // an older package — so an inlined definition is skipped rather than + // assumed impossible: it carries its own dispatcher and resolves nothing.) + const defs = Array.isArray(list.bulkActionDefs) ? (list.bulkActionDefs as AnyRec[]) : []; + for (let di = 0; di < defs.length; di++) { + const def = defs[di]; + if (!def || typeof def !== 'object') continue; + if (def.execution !== 'aggregate') continue; + if (def.actionDef !== undefined) continue; + const name = strName(def.name); + if (!name) continue; + check( + name, + `${owner} · ${label} · bulkActionDefs[${di}]`, + `${path}.bulkActionDefs[${di}].name`, + 'Aggregate bulk action', + SELECTION_BAR_PLACEMENT, + ); + } + }; + + // ── List views: `list` + each `listViews.`, on views AND on objects ── const views = asArray(stack.views); for (let vi = 0; vi < views.length; vi++) { const view = views[vi]; if (!view || typeof view !== 'object') continue; const viewName = strName(view.name) ?? strName(view.object) ?? `#${vi}`; + const owner = `view "${viewName}"`; - const checkListContainer = (container: unknown, label: string, path: string) => { - if (!container || typeof container !== 'object') return; - const list = container as AnyRec; - for (const key of ['rowActions', 'bulkActions'] as const) { - const names = strList(list[key]); - for (let ai = 0; ai < names.length; ai++) { - check( - names[ai], - `view "${viewName}" · ${label} · ${key}`, - `${path}.${key}[${ai}]`, - key === 'bulkActions' ? 'Bulk-action menu' : 'Row-action menu', - ); - } - } - }; - - checkListContainer(view.list, 'list', `views[${vi}].list`); + checkListContainer(view.list, owner, 'list', `views[${vi}].list`); const listViews = view.listViews; if (listViews && typeof listViews === 'object' && !Array.isArray(listViews)) { for (const [key, lv] of Object.entries(listViews as AnyRec)) { - checkListContainer(lv, `listViews.${key}`, `views[${vi}].listViews.${key}`); + checkListContainer(lv, owner, `listViews.${key}`, `views[${vi}].listViews.${key}`); } } } + // An object carries its own `listViews` (it has no top-level `list`), and a + // reference there is as dead as one in a standalone view — it was simply + // never walked. Object-EMBEDDED actions were already collected as + // definitions above; this is the consuming half. + const objects = asArray(stack.objects); + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!obj || typeof obj !== 'object') continue; + const objListViews = obj.listViews; + if (!objListViews || typeof objListViews !== 'object' || Array.isArray(objListViews)) continue; + const owner = `object "${strName(obj.name) ?? `#${oi}`}"`; + for (const [key, lv] of Object.entries(objListViews as AnyRec)) { + checkListContainer(lv, owner, `listViews.${key}`, `objects[${oi}].listViews.${key}`); + } + } + // ── Page components: record:quick_actions → properties.actionNames ── const pages = asArray(stack.pages); for (let pi = 0; pi < pages.length; pi++) { diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 59b78f6a68..e0e5e3daf9 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -68,31 +68,29 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { }); // #1870 — a `script` node that names no callable is a silent no-op. - it('flags a script node that declares neither actionType nor function (#1870)', () => { + it('flags a script node that declares no function (#1870)', () => { const issues = validateStackExpressions({ flows: [{ name: 'helpdesk_flow', nodes: [ { id: 'start', type: 'start', config: {} }, - { id: 'triage', type: 'script', config: { actionType: undefined } }, + { id: 'triage', type: 'script', config: {} }, ], edges: [], }], }); expect(issues).toHaveLength(1); expect(issues[0].where).toContain("node 'triage' (script) callable"); - expect(issues[0].message).toMatch(/neither .*actionType.* nor .*function/); + expect(issues[0].message).toMatch(/declares no .*function/); }); - it('accepts a script node that names a built-in action or a function (#1870)', () => { + it('accepts a script node that names a function (#1870)', () => { const issues = validateStackExpressions({ flows: [{ name: 'helpdesk_flow', nodes: [ { id: 'start', type: 'start', config: {} }, - { id: 'mail', type: 'script', config: { actionType: 'email' } }, { id: 'triage', type: 'script', config: { function: 'helpdesk.aiTriageStub' } }, - { id: 'inline', type: 'script', config: { script: 'variables.x = 1;' } }, ], edges: [], }], @@ -107,7 +105,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { name: 'helpdesk_flow', nodes: [ { id: 'start', type: 'start', config: {} }, - { id: 'triage', type: 'script', config: { actionType: 'invoke_function', functionName: 'helpdesk.aiTriageStub' } }, + { id: 'triage', type: 'script', config: { functionName: 'helpdesk.aiTriageStub' } }, ], edges: [], }], @@ -115,19 +113,56 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(issues).toHaveLength(0); }); - it('flags actionType invoke_function with no function/functionName', () => { + // #4343 — the retired dispatch keys. Naming them beats the generic "no + // callable": they are what the author actually wrote, and each branch has a + // different replacement. + it('flags a retired dispatch key and prescribes the replacement mechanism', () => { + const issues = validateStackExpressions({ + flows: [{ + name: 'helpdesk_flow', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'mail', type: 'script', config: { actionType: 'email', template: 't', recipients: ['a'] } }, + ], + edges: [], + }], + }); + expect(issues).toHaveLength(1); + expect(issues[0].message).toMatch(/#4343/); + expect(issues[0].message).toMatch(/config\.actionType/); + expect(issues[0].message).toMatch(/config\.template/); + expect(issues[0].message).toMatch(/`notify` node/); + expect(issues[0].message).toMatch(/os migrate meta --from 16/); + }); + + it('tells a shorthand actionType exactly where its name belongs', () => { const issues = validateStackExpressions({ flows: [{ name: 'helpdesk_flow', nodes: [ { id: 'start', type: 'start', config: {} }, - { id: 'triage', type: 'script', config: { actionType: 'invoke_function', inputs: { x: 1 } } }, + { id: 'triage', type: 'script', config: { actionType: 'helpdesk.aiTriageStub' } }, ], edges: [], }], }); expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/invoke_function.*no .*function/i); + expect(issues[0].message).toMatch(/function: 'helpdesk\.aiTriageStub'/); + }); + + it('flags an inline script body — the runtime never executed it', () => { + const issues = validateStackExpressions({ + flows: [{ + name: 'helpdesk_flow', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'inline', type: 'script', config: { script: 'variables.x = 1;' } }, + ], + edges: [], + }], + }); + expect(issues).toHaveLength(1); + expect(issues[0].message).toMatch(/config\.script/); }); // #1928 — bare field references are silently null in `record`-scoped sites @@ -578,6 +613,43 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(issues).toHaveLength(0); }); + /** + * #4439 — `decision.conditions[].expression` reaches the ledger through the + * SCHEMALESS channel (`decision` publishes no descriptor `configSchema`, so + * the marker rides `.meta({ xExpression })` on the Zod contract). Until then + * the ratchet could only see descriptor-declared slots, so this predicate — + * documented bare CEL, evaluated as bare CEL since #4414 — was checked by + * nobody and a `{…}` spelling passed `objectstack validate`. + */ + const decisionFlow = (expression: string) => ({ + objects, + flows: [{ + name: 'convert_lead', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'crm_lead' } }, + { id: 'check', type: 'decision', config: { conditions: [{ label: 'Yes', expression }] } }, + ], + edges: [], + }], + }); + + it('flags a `{var}` template dialect in a decision branch expression (#4439)', () => { + // The exact predicate app-crm shipped (#4414). + const issues = validateStackExpressions(decisionFlow("{lead_record.status} == 'converted'")); + const found = issues.filter(i => i.where.includes('conditions[0].expression')); + expect(found).toHaveLength(1); + expect(found[0].severity).toBe('error'); + expect(found[0].where).toContain("flow 'convert_lead'"); + expect(found[0].where).toContain("node 'check'"); + expect(found[0].where).toContain('decision branch expression'); + expect(found[0].source).toBe("{lead_record.status} == 'converted'"); + }); + + it('passes the corrected bare-CEL decision predicate (#4439)', () => { + const issues = validateStackExpressions(decisionFlow("lead_record.status == 'converted'")); + expect(issues.filter(i => i.where.includes('conditions'))).toHaveLength(0); + }); + it('leaves a correct single-brace loop collection alone', () => { // `loop.collection` is the single-brace `{var}` flow-interpolation dialect, // where braces are CORRECT. It is recorded in the ledger as `flow-template` diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 82758483f3..37d4b928bc 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -168,11 +168,12 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { found.value, ); } - // #1870 — a `script` node must declare a callable target (`actionType` or - // `function`). A node with neither is a silent no-op that otherwise passes - // build. (Function *existence* isn't checkable here — functions are code, - // not serialized into the artifact — so this is a structural check; the - // runtime verifies the named function is actually registered.) + // #1870 — a `script` node must name a callable, and since #4343 that is + // the whole of what the node does: `config.function`. A node without one + // is a silent no-op that otherwise passes build. (Function *existence* + // isn't checkable here — functions are code, not serialized into the + // artifact — so this is a structural check; the runtime verifies the + // named function is actually registered.) if (node.type === 'script') { // `function` is canonical; a pre-parse source may still carry the // `functionName` alias during the protocol-17 window, until the @@ -180,28 +181,34 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { const fn = (typeof cfg.function === 'string' ? cfg.function.trim() : '') || (typeof cfg.functionName === 'string' ? cfg.functionName.trim() : ''); + // A source that predates #4343 may still carry a retired dispatch key. + // Naming it beats the generic "no callable": these ARE what the author + // wrote, and each has a different replacement. The schema tombstones + // carry the full prescription; this is the one-line version at lint. const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : ''; - // Inline `config.script` (a JS body) is also a declared form — the - // built-in runtime doesn't execute it (warned at run time), but the node - // is not the empty no-op this check targets, so don't flag it. - const inline = typeof cfg.script === 'string' ? cfg.script.trim() : ''; - if (!fn && !action && !inline) { + const retired = ['actionType', 'template', 'recipients', 'variables', 'script'] + .filter((k) => cfg[k] != null); + if (retired.length > 0) { issues.push({ where: `${at} · node '${node.id}' (script) callable`, message: - `script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` + - `Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` + - `(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`, + `script node carries \`${retired.map((k) => `config.${k}`).join('`, `')}\` — retired in ` + + `@objectstack/spec 17 (#4343). The built-in 'email'/'slack' actions were logger-backed ` + + `stubs that delivered nothing, and inline \`config.script\` was never executed. ` + + (action && action !== 'invoke_function' && !['email', 'slack'].includes(action) + ? `\`actionType: '${action}'\` named a registered function — move it to \`function: '${action}'\`. ` + : `Use a \`notify\` node for mail, a \`connector_action\` (Slack connector) or \`http\` node ` + + `for Slack, and a registered function for logic. `) + + `Run \`os migrate meta --from 16\` to rewrite it automatically.`, source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), }); - } else if (action === 'invoke_function' && !fn) { - // `actionType: 'invoke_function'` is a marker that names no callable on - // its own — the function name must be in `function`/`functionName`. + } else if (!fn) { issues.push({ where: `${at} · node '${node.id}' (script) callable`, message: - `script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` + - `it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`, + `script node declares no \`function\` — it would do nothing at runtime. ` + + `Name a registered function (\`function: 'my_fn'\`, registered via ` + + `\`defineStack({ functions })\`).`, source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), }); } diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 55d00e9683..31ba8fa31a 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -5,6 +5,7 @@ import { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, + REACT_BLOCK_NEEDS_RECORD_CONTEXT, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; @@ -561,102 +562,98 @@ describe('validateReactPageProps — field props (#4340)', () => { }); }); -describe('validateReactPageProps — record:* blocks share the metadata table (#4340)', () => { - it('flags against its object', () => { - const f = unknownFields( - validateReactPageProps( - propsPage(jsx('RecordHighlights', `objectName="crm_account" recordId={1} fields={['name', 'nope']}`)), - ), - ); +/** + * #4413. These blocks were published by the react contract with + * `objectName`/`recordId` props, and no renderer read either: they take their + * record from the context a RECORD page mounts, and a react page mounts none. + * A page authored exactly to contract therefore rendered EMPTY, silently — + * and this rule, which used to resolve those props' field names against the + * object they named, was lint standing guard over a binding that never ran. + * + * The props are withdrawn; what is left to check is that the block is not here + * at all, loudly, at publish time. + */ +describe('validateReactPageProps — record:* blocks need a context this surface lacks (#4413)', () => { + const rejections = (f: PropFinding[]) => + f.filter((x) => x.rule === REACT_BLOCK_NEEDS_RECORD_CONTEXT); + + it('rejects each block the contract used to publish', () => { + for (const tag of ['RecordDetails', 'RecordHighlights', 'RecordRelatedList', 'RecordPath']) { + const f = validateReactPageProps( + propsPage(jsx(tag, `objectName="crm_account" recordId={1}`)), + ); + const rejected = rejections(f); + expect(rejected).toHaveLength(1); + expect(rejected[0].severity).toBe('error'); + expect(rejected[0].where).toBe(`page "p" › <${tag}>`); + expect(rejected[0].message).toContain('renders empty'); + } + }); + + it('rejects the record:* blocks that were never in the contract either', () => { + // The injected scope is built from the whole public registry, so these are + // just as reachable — and just as empty — as the four that were published. + const f = rejections(validateReactPageProps(propsPage(jsx('RecordActivity', `objectName="crm_account"`)))); expect(f).toHaveLength(1); - expect(f[0].path).toBe('pages[0].source › fields[1]'); - }); - - it('flags and its authored sections', () => { - const f = unknownFields( - validateReactPageProps( - propsPage( - jsx('RecordDetails', `objectName="crm_account" fields={['nope']} hideFields={['gone']}`), - ), - ), - ); - expect(f.map((x) => x.path)).toEqual([ - 'pages[0].source › fields[0]', - 'pages[0].source › hideFields[0]', - ]); + expect(f[0].message).toContain('record:activity'); }); - it('leaves alone when it is the declared string[] of section IDs', () => { - const f = validateReactPageProps( - propsPage(jsx('RecordDetails', `objectName="crm_account" layout="custom" sections={['overview', 'billing']}`)), + it('names a block that actually works in the hint', () => { + const [related] = rejections( + validateReactPageProps(propsPage(jsx('RecordRelatedList', `objectName="crm_invoice" recordId={1}`))), ); - expect(f).toEqual([]); - }); - - it('flags ', () => { - const f = unknownFields( - validateReactPageProps(propsPage(jsx('RecordPath', `objectName="crm_account" statusField="nope"`))), + expect(related.hint).toContain(' binds the CHILD object (#4340)', () => { - const related = (attrs: string) => jsx('RecordRelatedList', attrs); - it('resolves columns/sort/relationshipField against the RELATED object', () => { + it('says nothing else about a block it has already rejected', () => { + // No point resolving `columns` against an object for a block that cannot + // render here — one clear finding beats a pile of derived ones. const f = validateReactPageProps( - propsPage( - related( - `objectName="crm_invoice" recordId={1} relationshipField="account_id" columns={['name', 'total']}`, - ), - ), + propsPage(jsx('RecordRelatedList', `objectName="crm_account" columns={['nope']} statusField="gone"`)), ); - expect(f).toEqual([]); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe(REACT_BLOCK_NEEDS_RECORD_CONTEXT); }); - it('flags the parent-object mix-up the old contract gloss invited', () => { - // The exact shape #4340 found live: the author passed the PARENT object and - // named the child's columns + the child's own FK. Neither `total` nor - // `account_id` is on the account, so both positions report. - const f = unknownFields( + it('rejects the same components reached through ', () => { + const f = rejections( validateReactPageProps( - propsPage( - related(`objectName="crm_account" recordId={1} relationshipField="account_id" columns={['name', 'total']}`), - ), + propsPage(jsx('Block', `type="record:highlights" objectName="crm_account" fields={['name']}`)), ), ); - expect(f.map((x) => x.path)).toEqual([ - 'pages[0].source › columns[1]', - 'pages[0].source › relationshipField', - ]); - expect(f[0].message).toContain('"total"'); - expect(f[0].message).toContain('crm_account'); + expect(f).toHaveLength(1); + expect(f[0].where).toBe('page "p" › '); + expect(f[0].message).toContain('record:highlights'); }); - it('says nothing about relationshipValueField — the parent object is unbound here', () => { + it('leaves an author-defined component of the same name alone', () => { + // A local declaration shadows the injected scope, so this `` is + // the author's own component — flagging it would block a publish over a + // name collision. const f = validateReactPageProps( propsPage( - related(`objectName="crm_invoice" recordId={1} relationshipField="account_id" relationshipValueField="nope"`), + 'function Page(){ const RecordPath = ({ v }) => {v}; return ; }', ), ); expect(f).toEqual([]); }); - it('resolves the Add picker against its OWN object', () => { - const f = unknownFields( - validateReactPageProps( - propsPage( - related( - `objectName="crm_invoice" recordId={1} relationshipField="account_id" ` + - `add={{ picker: { object: 'crm_account', labelField: 'nope' }, linkField: 'total' }}`, - ), - ), - ), - ); - expect(f).toHaveLength(1); - expect(f[0].path).toBe('pages[0].source › add.picker.labelField'); + it('says nothing on a metadata page — this is a react-surface rule', () => { + const f = validateReactPageProps({ + objects: [account], + pages: [ + { + name: 'p', + kind: 'default', + regions: [{ components: [{ type: 'record:highlights', properties: { fields: ['name'] } }] }], + }, + ], + }); + expect(f).toEqual([]); }); }); @@ -664,7 +661,7 @@ describe('validateReactPageProps — escape hatch (#4340)', () => { it('checks the props bag by the registered type the author names', () => { const f = unknownFields( validateReactPageProps( - propsPage(jsx('Block', `type="record:highlights" objectName="crm_account" fields={['nope']}`)), + propsPage(jsx('Block', `type="element:form" objectName="crm_account" fields={['nope']}`)), ), ); expect(f).toHaveLength(1); @@ -672,15 +669,6 @@ describe('validateReactPageProps — escape hatch (#4340)', () => { expect(f[0].path).toBe('pages[0].source › fields[0]'); }); - it('reaches the related-list branch through too', () => { - const f = unknownFields( - validateReactPageProps( - propsPage(jsx('Block', `type="record:related_list" objectName="crm_account" columns={['total']}`)), - ), - ); - expect(f).toHaveLength(1); - }); - it('skips a type with no descriptor, and a non-static type', () => { const noSpec = validateReactPageProps( propsPage(jsx('Block', `type="object-kanban" objectName="crm_account" fields={['nope']}`)), diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 1d30a1d48c..0489e63c72 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -22,6 +22,10 @@ // metadata rule `searchable-field-unknown`, sharing its core. // - EVERY OTHER field-bearing prop a react block can author (#4340) — see // `REACT_FIELD_SPECS` below and the ledger beside it. +// - a `record:*` block on this surface at all (#4413) → error. They render +// from a record page's shared record context, which a react page does not +// mount, so they come back empty however they are bound. See +// `recordContextFinding`. // // Reading values is opt-in per block and per prop: everything below evaluates // only STATIC literals (`objectName="invoice"`, an `aggregate={{…}}` object @@ -31,7 +35,13 @@ import { createRequire } from 'node:module'; import type ts from 'typescript'; -import { REACT_BLOCKS, chartAggregateResultKeys } from '@objectstack/spec/ui'; +import { + REACT_BLOCKS, + RECORD_CONTEXT_BLOCK_TAGS, + REACT_RECORD_BLOCK_ALTERNATIVES, + chartAggregateResultKeys, + isRecordContextBlockType, +} from '@objectstack/spec/ui'; import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; import { checkSearchableFieldList, @@ -39,12 +49,10 @@ import { } from './validate-searchable-fields.js'; import { COMPONENT_FIELD_SPECS, - RELATED_LIST_TYPE, checkFieldRefs, componentFieldRefs, fieldRefsFrom, indexObjectFields, - relatedListFieldRefs, sortFieldRefs, type FieldRef, type PageFieldFinding, @@ -398,28 +406,25 @@ function checkObjectChart( // // ## Where the answers come from // -// The `record:*` blocks ARE the components that rule already walks — one -// registry component, two authoring surfaces — so they are not re-described -// here at all: `componentFieldRefs` / `relatedListFieldRefs` read the SAME -// `COMPONENT_FIELD_SPECS` table, keyed by the block's own `schemaType`. A prop -// added there is checked on both surfaces at once, which is the point. +// `REACT_FIELD_SPECS` below describes the blocks whose metadata twin lives +// under different prop names — `` (twin: a list page's +// `interfaceConfig`) and `` (twin: `element:form` + the +// form-layout rule) — plus ``'s `filter`. // -// `REACT_FIELD_SPECS` below covers only what the shared table cannot: the two -// blocks whose metadata twin lives under different prop names — -// `` (twin: a list page's `interfaceConfig`) and `` -// (twin: `element:form` + the form-layout rule). +// `COMPONENT_FIELD_SPECS`, the table `validate-page-field-bindings` walks on +// the metadata surface, is still read from here, keyed by `schemaType`, so a +// prop added there is checked on both surfaces at once. What reaches it is now +// only ``: this rule read that table mainly for the +// `record:*` blocks, and #4413 withdrew those from the tier entirely (they +// render from a record context this surface does not mount — see +// `recordContextFinding` below). The table's `record:*` rows are not dead, +// they are simply the metadata surface's alone again. // // ## What is deliberately NOT checked, and why // // - Anything non-static (a variable, a call, a value behind a spread) — // ADR-0072 D1: unresolvable is not wrong. `filters` is the one place this // is resolved PER POSITION rather than all-or-nothing; see below. -// - ``: it names a field on the -// PARENT object, and the react surface binds the parent by `recordId` -// only — there is no parent OBJECT to resolve against. The metadata twin -// has the page's object and checks it there. This is the ONE field-bearing -// prop in the index that stays unresolved, and the reason is a missing -// binding rather than a missing rule. // - ``'s axes: they name the aggregate's RESULT COLUMNS, not // fields, and `checkObjectChart` above already owns them. // @@ -630,8 +635,7 @@ function reactFieldRefs( * * 1. `REACT_FIELD_SPECS` — the react-only descriptors (`ListView`, * `ObjectForm`, `ObjectChart`'s `filter`). - * 2. the `record:related_list` split, when the block IS that component. - * 3. `COMPONENT_FIELD_SPECS`, keyed by the block's `schemaType` — the shared + * 2. `COMPONENT_FIELD_SPECS`, keyed by the block's `schemaType` — the shared * table the metadata surface already uses. `` reaches it * by the type the author wrote, which is what makes the escape hatch * checked rather than a hole. @@ -667,28 +671,19 @@ function checkBlockFieldProps( out.push(...checkFieldRefs(subs.parent, objectName, objectFields, where)); } - // `` renders the registered component the - // author names; every other block's type is fixed by its tag. + // `` renders the registered component the author + // names; every other block's type is fixed by its tag. A `record:*` type + // never arrives here — `recordContextFinding` rejected it upstream. const schemaType = tag === 'Block' ? strOf(values.get('type')) : SCHEMA_TYPE_BY_TAG.get(tag); - if (schemaType) { - const props = readableProps(values); - if (schemaType === RELATED_LIST_TYPE) { - const split = relatedListFieldRefs(props, path, PATH_SEP); - out.push(...checkFieldRefs(split.related, split.relatedObject, objectFields, where)); - out.push(...checkFieldRefs(split.picker, split.pickerObject, objectFields, where)); - // `split.parent` (`relationshipValueField`) is deliberately dropped: the - // react surface binds the parent RECORD (`recordId`) but never its - // object, so there is nothing to resolve it against. See the section note. - } else if (COMPONENT_FIELD_SPECS[schemaType]) { - out.push( - ...checkFieldRefs( - componentFieldRefs(schemaType, props, path, PATH_SEP) ?? [], - objectName, - objectFields, - where, - ), - ); - } + if (schemaType && COMPONENT_FIELD_SPECS[schemaType]) { + out.push( + ...checkFieldRefs( + componentFieldRefs(schemaType, readableProps(values), path, PATH_SEP) ?? [], + objectName, + objectFields, + where, + ), + ); } // The two finding shapes are structurally identical; `where`/`path` are @@ -696,6 +691,79 @@ function checkBlockFieldProps( return out as ReactPropFinding[]; } +// ─── The `record:*` family is not of this surface (#4413) ───────────────── +// +// Every `record:*` renderer takes its record from the context a RECORD PAGE +// mounts once (`RecordDetailView` fetches, N blocks render it, and they +// coordinate through it — highlights dedupe out of the detail grid, one +// inline-edit save bar commits them all under one version). A `kind:'react'` +// page mounts no such context: `useRecordContext()` returns null, and each +// block renders its designer placeholder — or, for `record:related_list`, +// refuses to fetch because the parent id never arrives. +// +// The react tier had published `objectName`/`recordId` on four of them anyway +// and no renderer read either, so a page authored exactly to contract rendered +// EMPTY with nothing reported anywhere — including by this file, which +// cheerfully resolved those props' field names against the object they named. +// #4413 withdrew the props (see the ledger in `@objectstack/spec/ui`); this +// turns what was a silent blank into a publish-time error, which is the half +// that keeps an AI author from writing them again from memory. +// +// The check is by TYPE, not by the withdrawn tag list: the scope objectui +// injects is built from the whole public registry, so every `record:*` +// component — including the six that were never in the contract +// (`record:activity`, `record:chatter`, …) — is reachable here and equally +// empty. `` is the same reach with the type spelled out. + +export const REACT_BLOCK_NEEDS_RECORD_CONTEXT = 'react-block-needs-record-context'; + +const RECORD_BLOCK_GENERIC_FIX = + 'author the page as `type:\'record\'` — a record page mounts the record context these blocks render from.'; + +function recordContextFinding( + tag: string, + schemaType: string, + where: string, + path: string, +): ReactPropFinding { + return { + severity: 'error', + rule: REACT_BLOCK_NEEDS_RECORD_CONTEXT, + where, + path, + message: + `<${tag}> renders "${schemaType}", which reads its record from the record context a ` + + `record page mounts — a kind:'react' page never mounts one, so the block renders empty ` + + `no matter how it is bound (its objectName/recordId are not read by the renderer).`, + hint: + `On a react page bind the record yourself: ` + + `${REACT_RECORD_BLOCK_ALTERNATIVES[schemaType] ?? RECORD_BLOCK_GENERIC_FIX}`, + }; +} + +/** + * Names the page source binds itself — every function/variable declaration, + * at any depth (a react page declares its helper components INSIDE `Page`). + * + * A local declaration SHADOWS the injected scope, so `const RecordPath = …` + * followed by `` is the author's own component and none of this + * rule's business. Cheap to honor and it removes the whole false-positive + * class from a gate that BLOCKS a publish — the older prop checks above are + * left alone deliberately: they only ever warn about a near-miss prop name or + * a missing required one, which is survivable advice on a shadowed tag. + */ +function localComponentNames(tsc: typeof ts, sf: ts.SourceFile): Set { + const names = new Set(); + const walk = (node: ts.Node): void => { + if (tsc.isFunctionDeclaration(node) && node.name) names.add(node.name.text); + else if (tsc.isVariableDeclaration(node) && tsc.isIdentifier(node.name)) names.add(node.name.text); + else if (tsc.isImportSpecifier(node)) names.add(node.name.text); + tsc.forEachChild(node, walk); + }; + walk(sf); + return names; +} + export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { const findings: ReactPropFinding[] = []; const objectFields = indexObjectFields(stack); @@ -723,9 +791,22 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { continue; // the syntax gate reports unparseable sources } + const locals = localComponentNames(tsc, sf); + const visit = (node: ts.Node): void => { if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) { const tag = node.tagName.getText(sf); + const where = `page "${name}" › <${tag}>`; + const path = `pages[${p}].source`; + // A withdrawn `record:*` block, reached by its injected tag. Reported + // and then dropped: the prop checks below have nothing useful to add + // about a block that cannot render here at all. + const recordType = RECORD_CONTEXT_BLOCK_TAGS.get(tag); + if (recordType && !locals.has(tag)) { + findings.push(recordContextFinding(tag, recordType, where, path)); + tsc.forEachChild(node, visit); + return; + } const block = BLOCKS.get(tag); if (block) { let hasSpread = false; @@ -744,8 +825,19 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { ); } } - const where = `page "${name}" › <${tag}>`; - const path = `pages[${p}].source`; + // The escape hatch reaches the same withdrawn components by type — + // `` is `` spelled + // out, and just as empty. Checked here rather than by tag because + // the type is an attribute VALUE (and a non-static one is + // unresolvable, not wrong — ADR-0072 D1). + if (tag === 'Block') { + const blockType = strOf(values.get('type')); + if (blockType && isRecordContextBlockType(blockType)) { + findings.push(recordContextFinding(tag, blockType, where, path)); + tsc.forEachChild(node, visit); + return; + } + } if (!hasSpread) { for (const req of block.requiredBindings) { if (!used.has(req)) { diff --git a/packages/metadata-core/src/protocol-handshake.test.ts b/packages/metadata-core/src/protocol-handshake.test.ts index 9d3e395f02..4afe818d0f 100644 --- a/packages/metadata-core/src/protocol-handshake.test.ts +++ b/packages/metadata-core/src/protocol-handshake.test.ts @@ -64,16 +64,65 @@ describe('rangeAdmitsMajor', () => { expect(rangeAdmitsMajor('workspace:*', 11)).toBeNull(); }); - it('bounds pathological input (ReDoS-safe) without a slow scan', () => { + it('bounds pathological input (ReDoS-safe) without catastrophic backtracking', () => { // The engines string is externally authored; the comparator/hyphen parsing // must not degrade on adversarial input (CodeQL alerts 837/838). - const overlong = '<' + '\t'.repeat(100_000); - const hyphenBomb = 'a\t-\t' + '\t'.repeat(100_000); - const start = performance.now(); - expect(rangeAdmitsMajor(overlong, 11)).toBeNull(); - expect(rangeAdmitsMajor(hyphenBomb, 11)).toBeNull(); - expect(rangeAdmitsMajor('>=11.0.0 ' + ' '.repeat(100_000) + '<13.0.0', 11)).toBeNull(); - expect(performance.now() - start).toBeLessThan(50); + // + // The adversarial shapes: an overlong comparator, a hyphen-range "bomb", and + // a comparator pair separated by a huge whitespace run. + const shapes = (scale: number) => [ + '<' + '\t'.repeat(scale), + 'a\t-\t' + '\t'.repeat(scale), + '>=11.0.0 ' + ' '.repeat(scale) + '<13.0.0', + ]; + + // 1. Behaviour: every shape is *unrecognized*, never a false rejection. + // This is the assertion that actually pins the contract. + for (const input of shapes(100_000)) { + expect(rangeAdmitsMajor(input, 11)).toBeNull(); + } + + // 2. Cost: the parse must stay linear in the input length. + // + // This deliberately asserts *no absolute wall-clock bound*. The previous + // 50ms ceiling measured machine load rather than the parser: under the + // full-repo run (~130 parallel turbo tasks) it exceeded 50ms on a healthy + // tree and reddened PRs that never touched this package (#4485). + // + // What the guard is really for is catastrophic backtracking — a regression + // that makes parsing *superlinear* in the input. So measure the scaling + // instead: the same shapes at 1x and 8x length. Load largely cancels out of + // a ratio, and min-of-N discards the samples the scheduler interrupted. + // + // Healthy (linear) parsing tracks the input at ~8x; measured repeatedly at + // 8.3-8.5x. The 40x ceiling therefore keeps ~5x headroom over healthy while + // still catching even a merely *quadratic* regression (which lands near + // 64x), let alone an exponential one — which would not finish at all. + // + // The two timings are taken back-to-back inside one iteration and the ratio + // is reduced by *minimum*, not the timings independently: a scheduler steal + // that lands in only one of the two windows would skew a ratio built from + // separately-minimised timings (observed reddening at 3x CPU + // oversubscription), whereas the cheapest single pair is the one iteration + // that ran cleanest end to end. + // + // NB: a ratio of pathological-to-benign input would NOT work here: a benign + // 16-char range parses ~300x faster than a 100k-char one purely because it + // is 100k characters shorter, which is linear scaling behaving correctly. + const small = shapes(100_000); + const big = shapes(800_000); + const scan = (inputs: readonly string[]): number => { + const t = performance.now(); + for (const input of inputs) rangeAdmitsMajor(input, 11); + return performance.now() - t; + }; + + for (let i = 0; i < 10; i++) scan(small); // warm the JIT before measuring + let ratio = Infinity; + for (let i = 0; i < 20; i++) { + ratio = Math.min(ratio, scan(big) / scan(small)); + } + expect(ratio).toBeLessThan(40); }); }); diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index e7e28d01bb..390e00ce11 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -15,6 +15,15 @@ export type { ExtendedOperation, } from './sys-metadata-repository.js'; +export { formatStoredMigrationReport, storedMigrationClean } from './stored-migration.js'; +export type { + StoredFlowCanonicalization, + StoredMigrationNotice, + StoredMigrationOutcome, + StoredMigrationReport, + StoredMigrationRow, +} from './stored-migration.js'; + export { computeMetadataDiagnostics, computeViewReferenceDiagnostics, diff --git a/packages/metadata-protocol/src/protocol.dropped-fields.test.ts b/packages/metadata-protocol/src/protocol.dropped-fields.test.ts index 07f52f36e3..c6be76d1d2 100644 --- a/packages/metadata-protocol/src/protocol.dropped-fields.test.ts +++ b/packages/metadata-protocol/src/protocol.dropped-fields.test.ts @@ -32,7 +32,11 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)', options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); return { id: 'rec-1', title: data.title }; }), - findOne: vi.fn(async () => null), + // The row EXISTS — `updateData`'s #4435 existence probe reads this, and a + // PATCH of an id that names no row is now a 404 rather than a 200 with a + // null record. These fixtures are about the strip channel, not about + // missing records, so they describe an engine that has the row. + findOne: vi.fn(async () => ({ id: 'rec-1' })), }; const p = new ObjectStackProtocolImplementation(engine as any); const res: any = await p.updateData({ @@ -56,7 +60,7 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)', options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); return { id: 'rec-1' }; }), - findOne: vi.fn(async () => null), + findOne: vi.fn(async () => ({ id: 'rec-1' })), }; const p = new ObjectStackProtocolImplementation(engine as any); const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: {} }); @@ -70,7 +74,7 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)', const engine = { registry: { getObject: () => SCHEMA }, update: vi.fn(async (_o: string, data: any) => ({ id: 'rec-1', ...data })), - findOne: vi.fn(async () => null), + findOne: vi.fn(async () => ({ id: 'rec-1' })), }; const p = new ObjectStackProtocolImplementation(engine as any); const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: { title: 'B' } }); diff --git a/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts new file mode 100644 index 0000000000..1f4e9c9800 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4498 — the flow-skip becomes ONE seam, and `duplicatePackage` stops minting + * pre-protocol flow rows. + * + * `convertStoredItem` returns `flow` bodies untouched, because flow-node + * conversions carry ADR-0078's open-namespace conflict guard and that needs the + * automation engine's live executor registry. #4454 built the capability + * (`AutomationEngine.canonicalizeStoredFlow`) and handed it to + * `migrateStoredMetadata` as an explicit hook, because the CLI has to boot an + * engine of its own to hold one. + * + * Inside a server there is nothing to thread: the protocol is constructed with + * an accessor for the kernel's service table. `resolveFlowCanonicalizer` reads + * the engine from it, which is what these tests pin — and it is what makes the + * skip fixable at `duplicatePackage`, a WRITE that was contradicting ADR-0087's + * "new rows are always canonical, so the stored pass is a strictly shrinking + * concern" on every duplication of a package containing a pre-17 flow. + */ +import { describe, expect, it, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** A flow body that passes `saveMetaItem`'s schema gate. */ +const flowBody = (config: Record) => ({ + name: 'purge_flow', + label: 'Purge Stale Leads', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'n1', type: 'delete_record', label: 'Purge', config }], + edges: [], +}); + +/** + * Stands in for `AutomationEngine.canonicalizeStoredFlow` — same contract, + * including the copy-on-write identity an unchanged body comes back with. + */ +const canonicalizeStoredFlow = (_name: string, body: any) => { + const node = body?.nodes?.[0]; + if (!node || !('filters' in (node.config ?? {}))) { + return { storable: body, notices: [], conflicts: [] }; + } + const { filters, ...rest } = node.config; + return { + storable: { ...body, nodes: [{ ...node, config: { ...rest, filter: filters } }] }, + notices: [{ + conversionId: 'flow-node-crud-filter-alias', + surface: 'flow.node.config.filter', + from: 'filters', + to: 'filter', + path: 'flows[0].nodes[0].config', + message: 'filters → filter', + }], + conflicts: [], + }; +}; + +function matches(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + if ((r[k] ?? null) !== v) return false; + } + return true; +} + +/** + * Protocol over a stub engine, with a services table under the caller's + * control — the whole point is that the protocol reads `automation` out of it + * rather than being handed a hook. + */ +function makeProtocol( + rows: Array>, + services: Map = new Map(), +) { + const seeded = rows.map((r, i) => ({ + id: `r_${i + 1}`, + organization_id: null, + package_id: null, + state: 'active', + checksum: `sha256:seed_${i + 1}`, + ...r, + metadata: typeof r.metadata === 'string' ? r.metadata : JSON.stringify(r.metadata), + })); + const engine: any = { + find: vi.fn(async (_t: string, opts?: { where?: Record }) => + seeded.filter((r) => matches(r, opts?.where ?? {}))), + registry: { + getPackage: vi.fn(() => ({ + manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application' }, + })), + installPackage: vi.fn(), + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine as never, () => services); + const saveMetaItem = vi.spyOn(protocol, 'saveMetaItem' as never); + (saveMetaItem as any).mockResolvedValue({ success: true } as never); + return { protocol, saveMetaItem, services }; +} + +describe('migrateStoredMetadata resolves the engine itself (#4498)', () => { + const legacyFlowRow = { + type: 'flow', + name: 'purge_flow', + metadata: flowBody({ objectName: 'lead', filters: { status: 'stale' } }), + }; + + it('canonicalizes a flow row with NO canonicalizeFlow threaded by the caller', async () => { + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow }]]), + ); + + // No hook. This is the whole claim: a caller running next to a live + // engine — an admin route, a server task — gets flow coverage for free. + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.rewritten).toBe(1); + expect(report.skipped).toBe(0); + const written = (saveMetaItem as any).mock.calls[0][0]; + expect(written.item.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); + expect(written.item.nodes[0].config).not.toHaveProperty('filters'); + }); + + it('an already-canonical flow row is counted canonical, not rewritten', async () => { + const { protocol, saveMetaItem } = makeProtocol( + [{ type: 'flow', name: 'purge_flow', metadata: flowBody({ objectName: 'lead', filter: { status: 'stale' } }) }], + new Map([['automation', { canonicalizeStoredFlow }]]), + ); + const report = await protocol.migrateStoredMetadata({ apply: true }); + expect(report.canonical).toBe(1); + expect(report.rewritten).toBe(0); + expect(saveMetaItem).not.toHaveBeenCalled(); + }); + + it('no automation service → skipped with the reason, never counted done', async () => { + const { protocol } = makeProtocol([legacyFlowRow], new Map()); + const report = await protocol.migrateStoredMetadata({ apply: true }); + expect(report.skipped).toBe(1); + expect(report.rewritten).toBe(0); + expect(report.rows[0]!.reason).toMatch(/no automation service is reachable/); + }); + + it('an automation service without canonicalizeStoredFlow is treated as absent', async () => { + // An older service in the slot answers the lookup but not the question. + // Reading that as "flow handled" would report a clean run over rows + // nothing examined. + const { protocol } = makeProtocol([legacyFlowRow], new Map([['automation', { registerFlow() {} }]])); + const report = await protocol.migrateStoredMetadata({ apply: true }); + expect(report.skipped).toBe(1); + expect(report.rows[0]!.reason).toMatch(/no automation service is reachable/); + }); + + it('an explicit canonicalizeFlow overrides the registry one', async () => { + const explicit = vi.fn((_n: string, body: any) => ({ + storable: { ...body, label: 'From the explicit hook' }, + notices: [], + conflicts: [], + })); + const registryHook = vi.fn(canonicalizeStoredFlow); + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow: registryHook }]]), + ); + + await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow: explicit }); + + expect(explicit).toHaveBeenCalledTimes(1); + expect(registryHook).not.toHaveBeenCalled(); + expect((saveMetaItem as any).mock.calls[0][0].item.label).toBe('From the explicit hook'); + }); + + it('resolution is LAZY — a service registered after construction is still found', async () => { + // Plugin init order does not guarantee `automation` is in the table when + // the protocol is assembled (the CLI adds it after ObjectQL by design). + // Caching `undefined` from a too-early read would disable flow + // canonicalization for the life of the process. + const services = new Map(); + const { protocol } = makeProtocol([legacyFlowRow], services); + services.set('automation', { canonicalizeStoredFlow }); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + expect(report.rewritten).toBe(1); + }); + + it('the engine is called with the row NAME, not the body', async () => { + const spy = vi.fn(canonicalizeStoredFlow); + const { protocol } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow: spy }]]), + ); + await protocol.migrateStoredMetadata({ apply: true }); + expect(spy.mock.calls[0][0]).toBe('purge_flow'); + }); +}); + +describe('duplicatePackage canonicalizes flow rows (#4498)', () => { + /** The row from the issue: a pre-17 `delete_record` carrying `config.filters`. */ + const legacyFlowRow = { + type: 'flow', + name: 'iojn_purge_flow', + package_id: 'app.iojn', + metadata: flowBody({ objectName: 'iojn_repair_ticket', filters: { status: 'stale' } }), + }; + const objectRow = { + type: 'object', + name: 'iojn_repair_ticket', + package_id: 'app.iojn', + metadata: { name: 'iojn_repair_ticket', label: 'Ticket', fields: { title: { type: 'text' } } }, + }; + const duplicate = (protocol: any) => protocol.duplicatePackage({ + sourcePackageId: 'app.iojn', + targetPackageId: 'app.iojn2', + targetNamespace: 'iojn2', + }); + + it('the copy lands CANONICAL — the guarantee the comment always claimed', async () => { + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow }]]), + ); + + const res = await duplicate(protocol); + + expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); + const written = (saveMetaItem as any).mock.calls[0][0]; + // Before #4498 this was `{ objectName, filters }` verbatim: a brand-new + // row in a pre-protocol dialect, minted by the platform itself. + // (`objectName` is untouched here because this package contains no + // `object` row to rename — the reference rewrite is covered next.) + expect(written.item.nodes[0].config).toEqual({ + objectName: 'iojn_repair_ticket', + filter: { status: 'stale' }, + }); + expect(written.item.nodes[0].config).not.toHaveProperty('filters'); + }); + + it('the reference rewrite still runs ON TOP of the canonical body', async () => { + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow, objectRow], + new Map([['automation', { canonicalizeStoredFlow }]]), + ); + await duplicate(protocol); + const flow = (saveMetaItem as any).mock.calls + .map((c: any) => c[0]) + .find((c: any) => c.type === 'flow'); + // Canonicalized (`filter`) AND re-namespaced (`iojn2_`) — the two passes + // compose; neither one replaces the other. + expect(flow.item.nodes[0].config.filter).toEqual({ status: 'stale' }); + expect(flow.item.nodes[0].config.objectName).toBe('iojn2_repair_ticket'); + }); + + it('a refused rename fails the item and names the token — never a silent legacy copy', async () => { + const conflicting = () => ({ + storable: {}, + notices: [], + conflicts: [{ + conversionId: 'flow-node-type-open-namespace', + token: 'http_request', + path: 'flows[0].nodes[0].type', + message: 'a custom executor owns this node type here', + }], + }); + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow: conflicting }]]), + ); + + const res = await duplicate(protocol); + + expect(res.failedCount).toBe(1); + expect(res.copiedCount).toBe(0); + expect(res.failed[0].error).toContain('http_request'); + expect(res.failed[0].error).toContain('live name in this environment'); + // The point of failing: copying the un-renamed body would mint exactly + // the row this fix exists to prevent. + expect(saveMetaItem).not.toHaveBeenCalled(); + }); + + it('a flow that cannot canonicalize fails the item with the reason', async () => { + const throwing = () => { throw new Error("Unrecognized key: '_uiPosition'"); }; + const { protocol, saveMetaItem } = makeProtocol( + [legacyFlowRow], + new Map([['automation', { canonicalizeStoredFlow: throwing }]]), + ); + + const res = await duplicate(protocol); + + expect(res.failedCount).toBe(1); + expect(res.failed[0].error).toMatch(/does not canonicalize.*_uiPosition/); + expect(saveMetaItem).not.toHaveBeenCalled(); + }); + + it('no engine reachable → the flow is copied as-is, and the duplication still succeeds', async () => { + // A control-plane / metadata-only host has no automation service. The + // copy is then no better than the source row already was — but failing + // a whole duplication over it would be a regression for a deployment + // that never runs flows at all. + const { protocol, saveMetaItem } = makeProtocol([legacyFlowRow], new Map()); + + const res = await duplicate(protocol); + + expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); + expect((saveMetaItem as any).mock.calls[0][0].item.nodes[0].config).toHaveProperty('filters'); + }); + + it('non-flow rows still go through the conversion chain', async () => { + const spy = vi.fn(canonicalizeStoredFlow); + const { protocol, saveMetaItem } = makeProtocol( + [objectRow], + new Map([['automation', { canonicalizeStoredFlow: spy }]]), + ); + await duplicate(protocol); + expect(spy).not.toHaveBeenCalled(); + const written = (saveMetaItem as any).mock.calls[0][0]; + expect(written.type).toBe('object'); + expect(written.name).toBe('iojn2_repair_ticket'); + }); + + it('an unparseable body is still reported as such, not as a canonicalization failure', async () => { + const { protocol } = makeProtocol( + [{ type: 'flow', name: 'iojn_broken', package_id: 'app.iojn', metadata: '{not json' }], + new Map([['automation', { canonicalizeStoredFlow }]]), + ); + const res = await duplicate(protocol); + expect(res.failed[0].error).toBe('unparseable metadata'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.record-not-found.test.ts b/packages/metadata-protocol/src/protocol.record-not-found.test.ts new file mode 100644 index 0000000000..363527d7ab --- /dev/null +++ b/packages/metadata-protocol/src/protocol.record-not-found.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4435] A write that touched zero rows must not report success. + * + * The READ path has always been honest — `getData` on an unknown id answers + * `404 RECORD_NOT_FOUND`. Both single-record WRITE paths disagreed: + * + * PATCH /data/showcase_task/definitely_not_a_row → 200 { record: null } + * DELETE /data/showcase_task/definitely_not_a_row → 200 { success: true } + * + * The REST layer is a pass-through (`res.json(await p.deleteData(...))`), so + * these are the protocol's answers, and this is where they are fixed. + * + * Why it matters beyond symmetry: a client PATCHing a record another session + * just deleted was told the write landed, and had to null-check a SUCCESS + * payload to find out otherwise. `DELETE` said `success: true` for any string in + * the path, so a typo'd id, an already-deleted row and a real deletion were + * indistinguishable — including on the bulk path, where a batch of typo'd ids + * reported every one of them deleted. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { name: 'task', fields: { title: { name: 'title', type: 'text' } } }; + +/** + * An engine whose `delete` honours the driver contract + * (`IDataDriver.delete` — "True if deleted, false if not found") and whose + * `findOne` answers from the same row set, so existence means one thing here. + */ +function makeProtocol(rows: Record = {}) { + const store = new Map(Object.entries(rows)); + const findOne = vi.fn(async (_object: string, opts: any) => store.get(String(opts?.where?.id)) ?? null); + const update = vi.fn(async (_object: string, data: any, opts: any) => { + const id = String(opts?.where?.id); + if (!store.has(id)) return null; + const next = { ...store.get(id), ...data }; + store.set(id, next); + return next; + }); + const del = vi.fn(async (_object: string, opts: any) => store.delete(String(opts?.where?.id))); + const engine = { + registry: { getObject: (n: string) => (n === 'task' ? SCHEMA : undefined) }, + findOne, update, delete: del, + }; + return { p: new ObjectStackProtocolImplementation(engine as any), findOne, update, del, store }; +} + +/** Assert the thrown value is the 404 envelope `getData` already produced. */ +async function expectRecordNotFound(run: () => Promise, id: string) { + let caught: any; + try { + await run(); + } catch (e) { + caught = e; + } + expect(caught, 'expected a RECORD_NOT_FOUND rejection, but the call resolved').toBeDefined(); + expect(caught.code).toBe('RECORD_NOT_FOUND'); + expect(caught.status).toBe(404); + expect(caught.object).toBe('task'); + expect(caught.message).toContain(id); + return caught; +} + +describe('[#4435] updateData refuses an id that names no row', () => { + it('PATCH of a nonexistent id is 404 RECORD_NOT_FOUND, not 200 { record: null }', async () => { + const { p, update } = makeProtocol({ real: { id: 'real', title: 'x' } }); + await expectRecordNotFound( + () => p.updateData({ object: 'task', id: 'definitely_not_a_row', data: { title: 'y' } } as any), + 'definitely_not_a_row', + ); + // …and the engine was never asked to write. A refused PATCH must not fire + // hooks, automation or an audit row for a record that does not exist. + expect(update).not.toHaveBeenCalled(); + }); + + it('the SAME id answers 404 on the read path — the two verbs agree now', async () => { + const { p } = makeProtocol({ real: { id: 'real' } }); + await expectRecordNotFound( + () => p.getData({ object: 'task', id: 'definitely_not_a_row' } as any), + 'definitely_not_a_row', + ); + }); + + it('an existing record still updates and returns the row', async () => { + const { p, update } = makeProtocol({ real: { id: 'real', title: 'x' } }); + const res: any = await p.updateData({ object: 'task', id: 'real', data: { title: 'y' } } as any); + expect(update).toHaveBeenCalledTimes(1); + expect(res).toMatchObject({ object: 'task', id: 'real', record: { title: 'y' } }); + }); + + it('the existence probe asks EXISTENCE, not the caller\'s visibility', async () => { + // Load-bearing, and the first cut of this fix got it backwards. Probing + // with the CALLER's context turns the existence gate into an authorization + // gate: a row the caller cannot read comes back null and the PATCH 404s. + // That would (a) move an RLS decision out of the write policy where #1994 + // put it, and (b) disarm `@proof: rls-by-id-write` — the dogfood fixture + // whose RED half asserts that a member who cannot READ a row but has no + // write policy still mutates it by id. A caller-scoped probe makes that + // proof go green, so the gate could no longer prove it can go red, and a + // future revert of #1994 would be masked by this probe. + // + // So the probe runs as system: "does this row exist", nothing more. + // Authorization stays inside engine.update, exactly where it was. + const { p, findOne } = makeProtocol({ real: { id: 'real' } }); + await p.updateData({ object: 'task', id: 'real', data: {}, context: { userId: 'u1' } } as any); + expect(findOne.mock.calls[0][1]).toMatchObject({ + where: { id: 'real' }, + context: { isSystem: true }, + }); + }); + + it('a PATCH the caller may not see is still decided by RLS, not by the probe', async () => { + // The row exists, so the probe passes it through to the engine — where the + // write policy answers, as it always has. The probe must not pre-empt it. + const { p, update } = makeProtocol({ hidden: { id: 'hidden' } }); + await p.updateData({ object: 'task', id: 'hidden', data: { title: 'x' }, context: { userId: 'nobody' } } as any); + expect(update).toHaveBeenCalledOnce(); + }); +}); + +describe('[#4435] deleteData reports what actually happened', () => { + it('DELETE of a nonexistent id is 404, not 200 { success: true }', async () => { + const { p } = makeProtocol({ real: { id: 'real' } }); + await expectRecordNotFound( + () => p.deleteData({ object: 'task', id: 'definitely_not_a_row' } as any), + 'definitely_not_a_row', + ); + }); + + it('a real deletion still answers success, and is no longer indistinguishable', async () => { + const { p, store } = makeProtocol({ real: { id: 'real' } }); + const res: any = await p.deleteData({ object: 'task', id: 'real' } as any); + expect(res).toEqual({ object: 'task', id: 'real', success: true }); + expect(store.has('real')).toBe(false); + }); + + it('deleting the same id twice: first 200, second 404', async () => { + const { p } = makeProtocol({ real: { id: 'real' } }); + await p.deleteData({ object: 'task', id: 'real' } as any); + await expectRecordNotFound(() => p.deleteData({ object: 'task', id: 'real' } as any), 'real'); + }); + + it('a driver return that is not the contract\'s `false` is NOT read as not-found', async () => { + // `=== false` on purpose. A driver that returns the deleted row, or an + // off-contract `undefined`, gives no POSITIVE not-found signal — inventing + // a 404 from a falsy return would break deletes against third-party + // drivers instead of reporting honestly. + const engine = { + registry: { getObject: () => SCHEMA }, + delete: vi.fn(async () => undefined), + }; + const p = new ObjectStackProtocolImplementation(engine as any); + await expect(p.deleteData({ object: 'task', id: 'whatever' } as any)) + .resolves.toMatchObject({ success: true }); + }); +}); + +describe('[#4435] deleteManyData reports per id, not per request', () => { + it('a batch of typo\'d ids no longer reports every one of them deleted', async () => { + const { p } = makeProtocol({ real: { id: 'real' } }); + const res: any = await p.deleteManyData({ + object: 'task', + ids: ['nonexistent_1'], + options: { continueOnError: true }, + } as any); + + // Pre-#4435: { succeeded: 1, failed: 0, results: [{ success: true }] }. + expect(res).toMatchObject({ success: false, total: 1, succeeded: 0, failed: 1 }); + expect(res.results[0]).toMatchObject({ id: 'nonexistent_1', success: false }); + expect(res.results[0].error).toContain('nonexistent_1'); + }); + + it('mixed ids are reported individually', async () => { + const { p } = makeProtocol({ a: { id: 'a' }, c: { id: 'c' } }); + const res: any = await p.deleteManyData({ + object: 'task', + ids: ['a', 'b', 'c'], + options: { continueOnError: true }, + } as any); + + expect(res).toMatchObject({ success: false, total: 3, succeeded: 2, failed: 1 }); + expect(res.results.map((r: any) => [r.id, r.success])).toEqual([ + ['a', true], ['b', false], ['c', true], + ]); + }); + + it('an all-real batch is unchanged', async () => { + const { p } = makeProtocol({ a: { id: 'a' }, b: { id: 'b' } }); + const res: any = await p.deleteManyData({ object: 'task', ids: ['a', 'b'] } as any); + expect(res).toMatchObject({ success: true, succeeded: 2, failed: 0 }); + }); + + it('a missing id stops the run without continueOnError, as a failure always has', async () => { + const { p, del } = makeProtocol({ b: { id: 'b' } }); + const res: any = await p.deleteManyData({ object: 'task', ids: ['a', 'b'] } as any); + expect(res).toMatchObject({ success: false, succeeded: 0, failed: 1 }); + expect(del).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts new file mode 100644 index 0000000000..5ee1c189df --- /dev/null +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -0,0 +1,577 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4327 — `os migrate meta --stored`: the read-path conversion chain gets a + * finish line. + * + * #3903/#4317 made every stored-row rehydration seam replay the full chain, so + * a legacy row *reads* canonical forever. It stayed legacy on disk, though — + * re-lowered on every load, warning once per boot. `migrateStoredMetadata` + * writes the canonical body back through the normal write path so the row + * itself stops carrying the old dialect. + * + * Rows are seeded straight into the stub engine, deliberately bypassing + * `saveMetaItem`'s schema gate — exactly like a real row written years ago + * under an older protocol. What the pass then does with them is the contract + * under test: preview writes nothing, apply rewrites through the repository + * (history row + fresh checksum + `source: 'migrate-stored'`), and everything + * it declines to touch says so instead of being silently counted clean. + */ +import { describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { formatStoredMigrationReport, storedMigrationClean } from './stored-migration.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + checksum: string | null; + metadata: string; +} + +function matches(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + if ((r[k] ?? null) !== v) return false; + } + return true; +} + +/** + * A multi-table stub engine: `sys_metadata` seeded from `seedRows`, every other + * table (`sys_metadata_history`, `sys_metadata_audit`) created on first write. + * The repository write path needs all of them, and the history table is where + * this feature's central claim — "re-saved through the normal write path" — is + * actually observable. + */ +function makeStubEngine( + seedRows: Array & { type: string; name: string; metadata: unknown }>, +) { + let nextId = 0; + const tables = new Map[]>(); + tables.set( + 'sys_metadata', + seedRows.map((r) => ({ + id: `r_${++nextId}`, + organization_id: null, + package_id: null, + state: 'active', + checksum: `sha256:seed_${nextId}`, + ...r, + metadata: typeof r.metadata === 'string' ? r.metadata : JSON.stringify(r.metadata), + })), + ); + const rowsOf = (t: string): Record[] => { + let rows = tables.get(t); + if (!rows) tables.set(t, (rows = [])); + return rows; + }; + + const engine: any = { + async find(t: string, opts?: { where?: Record }) { + return rowsOf(t).filter((r) => matches(r, opts?.where ?? {})); + }, + async findOne(t: string, opts?: { where?: Record }) { + return rowsOf(t).find((r) => matches(r, opts?.where ?? {})) ?? null; + }, + async insert(t: string, row: Record) { + const withId = { id: row.id ?? `r_${++nextId}`, ...row }; + rowsOf(t).push(withId); + return withId; + }, + async update(t: string, patch: Record, opts: { where: Record }) { + const target = rowsOf(t).find((r) => matches(r, opts.where)); + if (target) Object.assign(target, patch); + return target ?? { id: 'x' }; + }, + async delete() { return { deleted: 0 }; }, + registry: { + listItems: () => [], + isPackageDisabled: () => false, + registerItem: () => { /* no-op */ }, + registerObject: () => { /* no-op */ }, + }, + }; + return { engine, tables }; +} + +const metaRows = (tables: Map[]>) => tables.get('sys_metadata')!; +const historyRows = (tables: Map[]>) => + tables.get('sys_metadata_history') ?? []; + +/** + * A protocol-≤16 object row: `conditionalRequired` was removed from the spec in + * 17 (#3855) and its conversion is `retiredFromLoadPath` — the authored load + * seam refuses it, the stored seam keeps lowering it, and this pass persists + * the lowering. + */ +const legacyObjectRow = { + type: 'object', + name: 'crm_invoice', + metadata: { + name: 'crm_invoice', + label: 'Invoice', + fields: { + status: { type: 'select', label: 'Status' }, + amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + }, + }, +}; + +/** The same object, already canonical — the shape every row ends up in. */ +const canonicalObjectRow = { + type: 'object', + name: 'crm_quote', + metadata: { + name: 'crm_quote', + label: 'Quote', + fields: { + status: { type: 'select', label: 'Status' }, + amount: { type: 'currency', label: 'Amount', requiredWhen: "record.status == 'sent'" }, + }, + }, +}; + +/** A pre-17 standalone action row still carrying the removed `execute` alias. */ +const legacyActionRow = { + type: 'action', + name: 'convert', + metadata: { name: 'convert', label: 'Convert', type: 'script', object: 'crm_invoice', execute: 'convertHandler' }, +}; + +describe('migrateStoredMetadata — preview (#4327)', () => { + it('reports the rows carrying a pre-protocol shape and writes nothing', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow, canonicalObjectRow]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.apply).toBe(false); + expect(report.scanned).toBe(2); + expect(report.pending).toBe(1); + expect(report.canonical).toBe(1); + expect(report.rewritten).toBe(0); + // The whole point of a preview: the bytes are exactly as they were, and + // no history row was appended either. + expect(JSON.stringify(metaRows(tables))).toBe(before); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('names the conversion per row, so a preview is actionable rather than a count', async () => { + const { engine } = makeStubEngine([legacyObjectRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.rows).toHaveLength(1); + const row = report.rows[0]!; + expect(row).toMatchObject({ type: 'object', name: 'crm_invoice', outcome: 'pending', state: 'active' }); + expect(row.notices.length).toBeGreaterThan(0); + expect(row.notices[0]!.from).toBe('conditionalRequired'); + expect(row.notices[0]!.to).toBe('requiredWhen'); + // An already-canonical row is counted, never itemised — otherwise a + // healthy deployment's report is a wall of rows that need nothing. + expect(report.rows.every((r) => r.outcome !== 'canonical')).toBe(true); + }); + + it('is not "clean" while work remains — that verdict is what a CI gate reads', async () => { + const { engine } = makeStubEngine([legacyObjectRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + expect(storedMigrationClean(await protocol.migrateStoredMetadata())).toBe(false); + }); +}); + +describe('migrateStoredMetadata — apply (#4327)', () => { + it('rewrites the row in place with the canonical body', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.rewritten).toBe(1); + expect(report.pending).toBe(0); + expect(storedMigrationClean(report)).toBe(true); + + const stored = JSON.parse(metaRows(tables)[0]!.metadata); + expect(stored.fields.amount.requiredWhen).toBe("record.status == 'sent'"); + expect('conditionalRequired' in stored.fields.amount).toBe(false); + }); + + it('goes through the normal write path — history row, fresh checksum, migrate-stored source', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow]); + const seededChecksum = metaRows(tables)[0]!.checksum; + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.migrateStoredMetadata({ apply: true }); + + const history = historyRows(tables); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + type: 'object', + name: 'crm_invoice', + source: 'migrate-stored', + // The lineage is intact: the new version's parent is the row's + // pre-migration checksum, not a null "created from nothing". + previous_checksum: seededChecksum, + }); + // A rewritten body is a new content hash — the row is no longer + // addressed by the checksum its legacy bytes had. + expect(metaRows(tables)[0]!.checksum).not.toBe(seededChecksum); + expect(metaRows(tables)[0]!.checksum).toBe(history[0]!.checksum); + }); + + it('re-running is a no-op — the second pass finds every row canonical', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow, legacyActionRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const first = await protocol.migrateStoredMetadata({ apply: true }); + expect(first.rewritten).toBe(2); + + const second = await protocol.migrateStoredMetadata({ apply: true }); + expect(second.scanned).toBe(2); + expect(second.canonical).toBe(2); + expect(second.rewritten).toBe(0); + expect(second.rows).toHaveLength(0); + // Idempotence is the operator's verifiable statement: nothing new was + // written on the second run either. + expect(historyRows(tables)).toHaveLength(2); + }); + + it('rewrites a DRAFT row as a draft — the pass never promotes staged work live', async () => { + const { engine, tables } = makeStubEngine([{ ...legacyObjectRow, state: 'draft' }]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.rewritten).toBe(1); + expect(report.rows[0]).toMatchObject({ state: 'draft', outcome: 'rewritten' }); + expect(metaRows(tables)[0]!.state).toBe('draft'); + }); + + it('walks every org, not just the env-wide bucket', async () => { + const { engine, tables } = makeStubEngine([ + legacyObjectRow, + { ...legacyActionRow, organization_id: 'org_a' }, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.scanned).toBe(2); + expect(report.rewritten).toBe(2); + const orgRow = metaRows(tables).find((r) => r.organization_id === 'org_a')!; + expect(JSON.parse(orgRow.metadata).target).toBe('convertHandler'); + }); + + it('leaves archived rows alone — they are a record of what was, not served metadata', async () => { + const { engine, tables } = makeStubEngine([{ ...legacyObjectRow, state: 'archived' }]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.scanned).toBe(0); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBeDefined(); + }); + + it('restricts to the requested types', async () => { + const { engine } = makeStubEngine([legacyObjectRow, legacyActionRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, types: ['action'] }); + + expect(report.scanned).toBe(1); + expect(report.rows[0]).toMatchObject({ type: 'action', outcome: 'rewritten' }); + }); +}); + +describe('migrateStoredMetadata — flow rows via the canonicalizeFlow hook (#4454)', () => { + // A body the write path's schema gate accepts — the hook canonicalizes the + // shape, it does not exempt the row from validation. + const flowBody = (config: Record) => ({ + name: 'purge_flow', + label: 'Purge Stale Leads', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'n1', type: 'delete_record', label: 'Purge', config }], + edges: [], + }); + const flowRow = { + type: 'flow', + name: 'purge_flow', + metadata: flowBody({ objectName: 'lead', filters: { status: 'stale' } }), + }; + /** Stands in for `AutomationEngine.canonicalizeStoredFlow` — same contract. */ + const canonicalizeFlow = (_name: string, body: any) => { + const node = body?.nodes?.[0]; + if (!node || !('filters' in (node.config ?? {}))) { + // Copy-on-write: an unchanged body comes back BY REFERENCE, which is + // what the pass reads as "already canonical". + return { storable: body, notices: [], conflicts: [] }; + } + const { filters, ...rest } = node.config; + return { + storable: { ...body, nodes: [{ ...node, config: { ...rest, filter: filters } }] }, + notices: [{ + conversionId: 'flow-node-crud-filter-alias', + surface: 'flow.node.config.filter', + from: 'filters', + to: 'filter', + path: 'flows[0].nodes[0].config', + message: 'filters → filter', + }], + conflicts: [], + }; + }; + + it('rewrites a flow row when the caller supplies the engine hook', async () => { + const { engine, tables } = makeStubEngine([flowRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow }); + + expect(report.rewritten).toBe(1); + expect(report.skipped).toBe(0); + const stored = JSON.parse(metaRows(tables)[0]!.metadata); + expect(stored.nodes[0].config.filter).toEqual({ status: 'stale' }); + expect('filters' in stored.nodes[0].config).toBe(false); + expect(historyRows(tables)[0]).toMatchObject({ type: 'flow', source: 'migrate-stored' }); + }); + + it('still skips — with the reason — when no hook is supplied', async () => { + const { engine, tables } = makeStubEngine([flowRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.skipped).toBe(1); + expect(report.rows[0]!.reason).toMatch(/registerFlow/); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('counts an already-canonical flow as canonical, not as a rewrite', async () => { + const canonicalFlow = { + type: 'flow', + name: 'purge_flow', + metadata: flowBody({ objectName: 'lead', filter: { status: 'stale' } }), + }; + const { engine, tables } = makeStubEngine([canonicalFlow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow }); + + expect(report.canonical).toBe(1); + expect(report.rewritten).toBe(0); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('rewrites a flow the hook changed WITHOUT emitting a notice — the condition envelope case', async () => { + // The `{dialect, source}` envelope is a schema transform, not a + // conversion, so it reports no notice while still changing the body. + // Reading notices alone would call this row canonical and leave it + // re-deriving on every boot — the exact thing the pass exists to end. + const envelopeOnly = (_n: string, body: any) => ({ + storable: { + ...body, + edges: [{ ...body.edges[0], condition: { dialect: 'cel', source: "x == 'y'" } }], + }, + notices: [], + conflicts: [], + }); + const row = { + type: 'flow', + name: 'purge_flow', + metadata: { + ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), + edges: [{ id: 'e1', source: 'n1', target: 'n1', condition: "x == 'y'" }], + }, + }; + const { engine, tables } = makeStubEngine([row]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow: envelopeOnly }); + + expect(report.rewritten).toBe(1); + expect(JSON.parse(metaRows(tables)[0]!.metadata).edges[0].condition) + .toEqual({ dialect: 'cel', source: "x == 'y'" }); + }); + + it('fails the row loudly when the guard refuses a rename over a live name', async () => { + const conflicting = (_n: string, body: any) => ({ + storable: body, + notices: [], + conflicts: [{ + conversionId: 'flow-node-type-rename', + token: 'webhook', + path: 'flows[0].nodes[0].type', + message: "'webhook' is registered by a custom executor in this environment.", + }], + }); + const { engine, tables } = makeStubEngine([flowRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow: conflicting }); + + expect(report.failed).toBe(1); + expect(report.rewritten).toBe(0); + expect(report.rows[0]!.reason).toMatch(/live name/); + expect(report.rows[0]!.reason).toMatch(/webhook/); + // Never a silent skip and never a clobber — the owner's node survives. + expect(historyRows(tables)).toHaveLength(0); + expect(storedMigrationClean(report)).toBe(false); + }); + + it('reports a flow that cannot canonicalize instead of persisting a guess', async () => { + const throwing = () => { throw new Error('Unrecognized key(s) on this flow: `_uiPosition`'); }; + const { engine, tables } = makeStubEngine([flowRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow: throwing }); + + expect(report.failed).toBe(1); + expect(report.rows[0]!.reason).toMatch(/does not canonicalize/); + expect(report.rows[0]!.reason).toMatch(/_uiPosition/); + expect(historyRows(tables)).toHaveLength(0); + }); +}); + +describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', () => { + it('skips flow rows and names the seam that owns them', async () => { + const legacyFlow = { + type: 'flow', + name: 'purge_flow', + metadata: { + name: 'purge_flow', + nodes: [{ id: 'n1', type: 'delete_record', config: { objectName: 'lead', filters: { status: 'stale' } } }], + }, + }; + const { engine, tables } = makeStubEngine([legacyFlow]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.skipped).toBe(1); + expect(report.rewritten).toBe(0); + expect(report.rows[0]!.reason).toMatch(/registerFlow/); + expect(JSON.parse(metaRows(tables)[0]!.metadata).nodes[0].config.filters).toEqual({ status: 'stale' }); + // A skipped row is a documented carve-out, not unfinished work: it does + // not fail the run's verdict, but the report always names it. + expect(storedMigrationClean(report)).toBe(true); + }); + + it('skips a type with no repository write path rather than rewriting it without history', async () => { + // `agent` is allowOrgOverride:false + allowRuntimeCreate:false, so + // `saveMetaItem` would take the legacy raw-engine branch: no history row + // and a forced `state: 'active'`. Declining beats a silent half-write. + const { engine, tables } = makeStubEngine([ + { type: 'agent', name: 'legacy_agent', metadata: { name: 'legacy_agent', label: 'Legacy' } }, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.skipped).toBe(1); + expect(report.rows[0]!.reason).toMatch(/no repository write path/); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('reports a row whose body is not JSON instead of throwing the whole run away', async () => { + const { engine } = makeStubEngine([ + { type: 'object', name: 'broken', metadata: '{ not json' }, + legacyObjectRow, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.failed).toBe(1); + expect(report.rewritten).toBe(1); + expect(report.rows.find((r) => r.name === 'broken')!.reason).toMatch(/not valid JSON/); + expect(storedMigrationClean(report)).toBe(false); + }); + + it('reports — and does not write — a row that still fails the schema after conversion', async () => { + // `fields` as a number is a genuine contract violation no conversion + // owns. `saveMetaItem`'s 422 is correct, and the row keeps reading + // through the chain: this pass records the refusal rather than + // bypassing the gate that new rows are held to. + const { engine, tables } = makeStubEngine([ + { + type: 'object', + name: 'corrupt_thing', + metadata: { + name: 'corrupt_thing', + label: 'Corrupt', + fields: { amount: { type: 'currency', conditionalRequired: 'x' } }, + // an off-contract key the schema rejects and no conversion lowers + listViews: 42, + }, + }, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.failed).toBe(1); + expect(report.rewritten).toBe(0); + expect(report.rows[0]!.reason).toMatch(/invalid_metadata/); + expect(historyRows(tables)).toHaveLength(0); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBe('x'); + }); + + it('does not clobber a row a concurrent writer moved — the optimistic lock is real', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow]); + // Someone else saved between the pass's scan and its write: the scan is + // handed the checksum the body was read under, the row on disk has + // already moved on. + metaRows(tables)[0]!.checksum = 'sha256:moved_by_someone_else'; + const protocol = new ObjectStackProtocolImplementation(engine); + const originalFind = engine.find.bind(engine); + let served = false; + engine.find = async (t: string, opts?: any) => { + const rows = await originalFind(t, opts); + if (t === 'sys_metadata' && !served) { + served = true; + return rows.map((r: any) => ({ ...r, checksum: 'sha256:stale' })); + } + return rows; + }; + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.failed).toBe(1); + expect(report.rows[0]!.reason).toMatch(/metadata_conflict/); + // The other writer's row is untouched. + expect(metaRows(tables)[0]!.checksum).toBe('sha256:moved_by_someone_else'); + }); +}); + +describe('formatStoredMigrationReport (#4327)', () => { + it('leads with the verdict when everything is already canonical', async () => { + const { engine } = makeStubEngine([canonicalObjectRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const lines = formatStoredMigrationReport(await protocol.migrateStoredMetadata()); + expect(lines.join('\n')).toMatch(/already on protocol/); + }); + + it('refuses to call an empty scan clean — that is the wrong-directory reading too', async () => { + const { engine } = makeStubEngine([]); + const protocol = new ObjectStackProtocolImplementation(engine); + const report = await protocol.migrateStoredMetadata(); + expect(report.scanned).toBe(0); + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toMatch(/attests nothing/); + expect(text).not.toMatch(/already on protocol/); + }); + + it('prints each pending row with the conversion that would fire', async () => { + const { engine } = makeStubEngine([legacyObjectRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const text = formatStoredMigrationReport(await protocol.migrateStoredMetadata()).join('\n'); + expect(text).toMatch(/object\/crm_invoice \[env-wide\]/); + expect(text).toMatch(/conditionalRequired → requiredWhen/); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cddb8e7cc1..a3fb503d90 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19,7 +19,7 @@ import type { import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { - parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, + parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots, @@ -27,10 +27,10 @@ import { type DroppedFieldsEvent, type QueryAST, } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; -import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; -import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system'; -import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed } from '@objectstack/spec/kernel'; +import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; +import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import { extractProtection, evaluateLockForWrite, @@ -49,6 +49,12 @@ import { decorateMetadataItems, type MetadataDiagnostics, } from './metadata-diagnostics.js'; +import type { + StoredFlowCanonicalization, + StoredMigrationNotice, + StoredMigrationReport, + StoredMigrationRow, +} from './stored-migration.js'; /** * Canonical Zod schema per metadata type lives in @@ -72,6 +78,43 @@ import { */ 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. + */ +function canonicalMetaType(type: string): string { + return PLURAL_TO_SINGULAR[type] ?? type; +} + +/** {@link canonicalMetaType} applied to a `{ type }` request, without mutating the caller's object. */ +function canonicalizeMetaRequestType(request: T): T { + const type = canonicalMetaType(request.type); + return type === request.type ? request : { ...request, type }; +} + /** * [#3770] One-shot flag for the "engine has no schema registry" warning emitted * by {@link ObjectStackProtocolImplementation.assertObjectRegistered}. The @@ -202,8 +245,14 @@ const HAND_CRAFTED_SCHEMAS: Record> = { component: { type: 'string' }, visible: { type: 'string' }, disabled: { type: 'string' }, - shortcut: { type: 'string' }, - bulkEnabled: { type: 'boolean', default: false }, + // 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' }, @@ -309,6 +358,34 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null return getMetadataTypeSchema(singular) ?? null; } +/** + * [#4435] The 404 a single-record operation answers when the id names no row. + * + * Extracted so the READ and the two WRITE paths cannot disagree about it. They + * did: `getData` answered `404 RECORD_NOT_FOUND` while `updateData` returned + * `200 { record: null }` and `deleteData` returned `200 { success: true }` for + * any string in the path — so a typo'd id, an already-deleted row and a real + * deletion were indistinguishable, and a client PATCHing a concurrently deleted + * record was told its write had landed. + * + * That is the same silent-no-op shape the v17 train removed everywhere else + * this window (#4240/#4303/#4315 refuse missing fields, #4169 refuses unknown + * params, #4190 stopped dropping filters) — a write that touched zero rows + * reporting 200 is that shape one level up, on the verb where it costs the + * most. + */ +function recordNotFoundError(object: string, id: string | number): Error { + const err = new Error(`Record ${id} not found in ${object}`) as Error & { + code?: string; + status?: number; + object?: string; + }; + err.code = 'RECORD_NOT_FOUND'; + err.status = 404; + err.object = object; + return err; +} + /** * A 400 for a `$filter` ARRAY that looks like a filter AST but is not one. * @@ -1194,9 +1271,16 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string * Service Configuration for Discovery * Maps service names to their routes and plugin providers. * - * `route: undefined` means the service has NO HTTP surface — discovery must + * 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). + * 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 @@ -1212,7 +1296,11 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string * 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 = { +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 @@ -1220,18 +1308,37 @@ const SERVICE_CONFIG: Record = { analytics: { route: '/api/v1/analytics' }, auth: { route: '/api/v1/auth' }, automation: { route: '/api/v1/automation' }, - cache: { route: '/api/v1/cache' }, - queue: { route: '/api/v1/queue' }, - job: { route: '/api/v1/jobs' }, + // 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' }, + // `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). - realtime: {}, + // /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. + 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' }, // GraphQL uses /graphql by convention (not versioned REST) + // `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. 'file-storage': { route: '/api/v1/storage' }, search: { route: '/api/v1/search' }, }; @@ -1260,7 +1367,8 @@ const REFERENCE_PATHS: Record { + const name = (data as { name?: unknown } | null | undefined)?.name; + const key = `${n.conversionId}|${singular}|${String(name ?? '')}`; + if (this.storedConversionWarned.has(key)) return; + this.storedConversionWarned.add(key); + console.warn( + `[Protocol] stored ${singular}/${String(name ?? '')} carries a pre-protocol shape; ` + + `${n.message} The row itself is unchanged — re-save it (Studio edit → save, or run ` + + `"os migrate meta --stored --apply") to persist the canonical shape.`, + ); + }).item; + } + + /** + * {@link convertStoredItem} with the chain's notices handed back instead of + * only logged — what {@link migrateStoredMetadata} reports per row (#4327). + * + * The notices ARE the change signal: a conversion emits exactly one per + * rewrite it performs (ADR-0087 D2 "loud"), so an empty list means the row + * is already canonical and there is nothing to persist. Comparing bodies + * instead would be weaker — the pass is copy-on-write, so an untouched + * branch is shared and a re-serialized identical body can still differ in + * key order. + */ + private convertStoredItemDetailed( + type: string, + data: unknown, + onNotice?: (notice: ConversionNotice) => void, + ): { item: unknown; notices: ConversionNotice[] } { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (singular === 'flow') return { item: data, notices: [] }; + const notices: ConversionNotice[] = []; + const item = applyConversionsToStoredItem(singular, data, { onNotice: (n) => { - const name = (data as { name?: unknown } | null | undefined)?.name; - const key = `${n.conversionId}|${singular}|${String(name ?? '')}`; - if (this.storedConversionWarned.has(key)) return; - this.storedConversionWarned.add(key); - console.warn( - `[Protocol] stored ${singular}/${String(name ?? '')} carries a pre-protocol shape; ` + - `${n.message} The row itself is unchanged — re-save it (Studio edit → save) to persist the canonical shape.`, - ); + notices.push(n); + onNotice?.(n); }, }); + return { item, notices }; + } + + /** + * Resolve a flow canonicalizer from the live services registry (#4498). + * + * `convertStoredItem` skips `flow` because flow-node conversions carry + * ADR-0078's open-namespace conflict guard, which needs the automation + * engine's executor registry to tell a rename from a clobber. #4454 built + * that capability as `AutomationEngine.canonicalizeStoredFlow` and handed + * it to `migrateStoredMetadata` as an explicit hook, because the CLI has + * to boot an engine of its own to hold one. + * + * Inside a server there is nothing to thread: this protocol is constructed + * with an accessor for the kernel's service table (the same one + * `analytics` and `package` are read from), and the automation service + * registers itself under `automation`. So every caller running next to a + * live engine can have the capability for free — which is what makes the + * flow-skip fixable at `duplicatePackage` (a WRITE that was minting new + * pre-protocol rows) rather than only at the CLI. + * + * Resolution is deliberately **lazy** — per call, never cached at + * construction. Plugin init order is not guaranteed to put `automation` + * in the table before the protocol is assembled (the CLI's + * `buildDataMigrationPlugins` adds it after ObjectQL by design), and + * caching `undefined` from a too-early read would silently disable flow + * canonicalization for the life of the process. + * + * Returns `undefined` when no engine is reachable. That is a real state — + * a control-plane or metadata-only host has no automation service — and + * every caller must decide what it means for them rather than assume a + * flow was handled. + */ + private resolveFlowCanonicalizer(): + ((name: string, body: unknown) => StoredFlowCanonicalization) | undefined { + const automation = this.getServicesRegistry?.().get('automation') as + | { canonicalizeStoredFlow?: (name: string, definition: unknown) => StoredFlowCanonicalization } + | undefined; + const canonicalize = automation?.canonicalizeStoredFlow; + if (typeof canonicalize !== 'function') return undefined; + return (name, body) => canonicalize.call(automation, name, body); } constructor( @@ -2077,12 +2251,14 @@ export class ObjectStackProtocolImplementation implements // Registered — but honor a stub/dev/fallback self-description // instead of blindly reporting 'available' (ADR-0076 D12). const self = readServiceSelfInfo(registeredServices.get(serviceName)); - // No HTTP surface at all (e.g. realtime): the handler can never - // be ready and 'available' would overstate it — report degraded. + // No HTTP surface at all: the handler can never be ready, and + // the entry's own `noHttpSurface` declaration says whether that + // also degrades the slot (realtime) or not (cache/queue/job — + // in-process contracts, #4318). const noHttpSurface = !config.route; services[serviceName] = { enabled: true, - status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)), + status: self?.status ?? (config.noHttpSurface?.statusWhenUnmarked ?? ('available' as const)), route: advertisedRoute(serviceName, config.route), provider: CORE_SERVICE_PROVIDER[serviceName] ?? undefined, ...(noHttpSurface || self?.handlerReady !== undefined @@ -2090,8 +2266,8 @@ export class ObjectStackProtocolImplementation implements : {}), ...(self?.message ? { message: self.message } - : noHttpSurface - ? { message: 'In-process service only — no HTTP surface is mounted' } + : config.noHttpSurface + ? { message: config.noHttpSurface.message } : {}), }; } else { @@ -2110,7 +2286,6 @@ export class ObjectStackProtocolImplementation implements auth: 'auth', automation: 'automation', ui: 'ui', - workflow: 'workflow', realtime: 'realtime', notification: 'notifications', ai: 'ai', @@ -2383,6 +2558,15 @@ export class ObjectStackProtocolImplementation implements } async getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one + // is load-bearing twice over: the SchemaRegistry indexes code-authored + // items under the SINGULAR type, and the overlay-hydration branch below + // registers overlay rows back into it under `request.type`. Called with + // the plural spelling, that branch minted a PLURAL registry entry — and + // once `listItems('actions')` was non-empty, the singular fallback that + // had been supplying the 11 code-authored actions stopped running. One + // overlay row shadowed the entire code-authored listing. + request = canonicalizeMetaRequestType(request); const { packageId } = request; let items: unknown[] = []; @@ -2682,6 +2866,8 @@ export class ObjectStackProtocolImplementation implements } async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. + request = canonicalizeMetaRequestType(request); let item: unknown; const orgId = request.organizationId; // Studio's editor opens a draft buffer with `state: 'draft'`; @@ -2999,6 +3185,10 @@ export class ObjectStackProtocolImplementation implements }> { const orgId = request.organizationId; + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The + // three-layer diagnostic must answer for ONE namespace, or `code` and + // `overlay` can be read from two. + request = canonicalizeMetaRequestType(request); // ── code layer: MetadataService.get + registry, BYPASSING overlay ── let code: unknown | null = null; try { @@ -3586,7 +3776,11 @@ export class ObjectStackProtocolImplementation implements * expansion does. {@link REFERENCE_VALUE_TYPES} is the spec's own list of * types whose value "points at another record … the related record object * in expanded form" — the same set `engine.expandRelatedRecords` resolves, - * so this gate cannot drift from what expansion actually delivers. + * so this gate cannot drift from what expansion actually delivers. The + * "does it name a target" half reads `referenceTargetOf` for the same + * reason: the engine resolves the target through that one function, so a + * type whose target is implied (`user` ⇒ `sys_user`) can never be refused + * here and expanded there. */ private assertExpandTargetsExist(object: string, names: readonly string[]): void { if (names.length === 0) return; @@ -3600,12 +3794,19 @@ export class ObjectStackProtocolImplementation implements const def: any = gate.fields[name.split('.')[0]]; if (!def) { unknown.push(name); continue; } if (!REFERENCE_VALUE_TYPES.has(def.type)) { notRelations.push(name); continue; } - // A reference-typed field with no `reference` names no target - // object, so `expandRelatedRecords` has nothing to batch-load. That - // is an authoring bug on the OBJECT, not on the request, and saying - // "not a relationship" about a declared lookup would send the - // caller looking in the wrong place. - if (!def.reference) targetless.push(name); + // A reference-typed field that names no target object leaves + // `expandRelatedRecords` nothing to batch-load. That is an + // authoring bug on the OBJECT, not on the request, and saying "not + // a relationship" about a declared lookup would send the caller + // looking in the wrong place. + // + // `referenceTargetOf` — not a raw `def.reference` read — because + // some reference types carry their target in the TYPE (`user` ⇒ + // `sys_user`) rather than in an author-written `reference`. The + // engine's expand loop resolves the target through the same + // function, which is what keeps this gate from refusing a field + // expansion would have delivered (cloud#983). + if (!referenceTargetOf(def)) targetless.push(name); } const [offenders, reason] = unknown.length > 0 ? [unknown, 'unknown' as const] @@ -4528,15 +4729,7 @@ export class ObjectStackProtocolImplementation implements record: result }; } - const err = new Error(`Record ${request.id} not found in ${request.object}`) as Error & { - code?: string; - status?: number; - object?: string; - }; - err.code = 'RECORD_NOT_FOUND'; - err.status = 404; - err.object = request.object; - throw err; + throw recordNotFoundError(request.object, request.id); } async createData(request: { object: string, data: any, context?: any }) { @@ -4605,13 +4798,7 @@ export class ObjectStackProtocolImplementation implements request.object, { where: { id: request.id }, ...(ctxOpt as any) } as any, ); - if (!source) { - const err: any = new Error(`Record ${request.id} not found in ${request.object}`); - err.code = 'RECORD_NOT_FOUND'; - err.status = 404; - err.object = request.object; - throw err; - } + if (!source) throw recordNotFoundError(request.object, request.id); // Copy the source, then strip the columns the engine owns so the insert // path re-derives them rather than carrying the source's values over. @@ -4651,7 +4838,35 @@ export class ObjectStackProtocolImplementation implements async updateData(request: { object: string, id: string, data: any, expectedVersion?: string, context?: any }) { this.assertObjectRegistered(request.object); // [#3770] - await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context); + // [#4435] ONE probe serves both gates. + // + // A PATCH of an id that names no row answered `200 { record: null }` — + // the caller had to null-check a SUCCESS payload to discover its write + // never landed, which is exactly what a client that PATCHes a + // concurrently deleted record does not do. `getData` has always + // answered 404 for the same id; the two verbs now agree. + // + // Existence is asked BEFORE the write rather than read off what comes + // back: the engine's update returns the post-write READBACK, which is + // also `null` when the row still exists but the write moved it out of + // the caller's row scope (reassigning `owner_id` away from yourself + // under an owner-scoped RLS policy). Reading that as "not found" would + // answer 404 to a write that succeeded. So ask existence directly. + // + // The probe asks EXISTENCE, not visibility — see `probeRecord` for why + // that distinction is load-bearing (it keeps this fix out of the RLS + // model and keeps the #1994 by-id-write proof able to go red). + // + // OCC already had to read the same row for its `updated_at`, so the two + // gates share this single read instead of issuing a probe each. Two + // round-trips per PATCH would have been a performance regression no + // gate reports, and the second read could even disagree with the first. + const current = await this.probeRecord(request.object, request.id); + if (!current) throw recordNotFoundError(request.object, request.id); + // 404 wins over 409 when both could apply: OCC has always declined to + // treat a missing record as a concurrency conflict, and "this record + // does not exist" is the more specific answer. + this.assertVersionOf(request.object, request.id, current, request.expectedVersion); const opts: any = { where: { id: request.id } }; if (request.context !== undefined) opts.context = request.context; // [#3407/#3431] Capture the engine's LEGAL write strips (static `readonly` @@ -4673,10 +4888,24 @@ export class ObjectStackProtocolImplementation implements async deleteData(request: { object: string, id: string, expectedVersion?: string, context?: any }) { this.assertObjectRegistered(request.object); // [#3770] - await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context); + await this.assertVersionMatch(request.object, request.id, request.expectedVersion); const opts: any = { where: { id: request.id } }; if (request.context !== undefined) opts.context = request.context; - await this.engine.delete(request.object, opts); + const deleted = await this.engine.delete(request.object, opts); + // [#4435] `success: true` used to be a LITERAL — the response said the + // same thing for a real deletion, an already-deleted row and a typo'd + // id, so nothing on the wire could tell them apart. The driver contract + // (`IDataDriver.delete` — "True if deleted, false if not found") already + // carries the answer; it was simply discarded here. Now it decides: + // `false` is a 404, matching `getData` on the same id, and `success` on + // the 200 finally means what it says. + // + // Read as `=== false` on purpose. That is the contract's own value for + // "no row matched"; anything else — a driver returning the deleted row, + // an `undefined` from an off-contract implementation — is not a + // POSITIVE not-found signal, and inventing a 404 out of it would break + // deletes against third-party drivers rather than report honestly. + if (deleted === false) throw recordNotFoundError(request.object, request.id); return { object: request.object, id: request.id, @@ -4685,35 +4914,72 @@ export class ObjectStackProtocolImplementation implements } /** - * Optimistic Concurrency Control gate shared by updateData/deleteData. + * [#4435] Does this row EXIST? A fact about the database — deliberately + * NOT "may this caller see it". + * + * Read with a system context so no row-level policy narrows it. That is + * load-bearing, and the first version of this fix got it wrong: probing + * with the CALLER's context turns the existence gate into an authorization + * gate, because a row the caller cannot read comes back `null` and the + * PATCH answers 404. Two things break when it does. + * + * 1. It silently changes RLS semantics. Whether an unreadable row may be + * written by id is the #1994 pre-image check's decision, made inside + * `engine.update` where the write policy lives. A probe in front of it + * quietly adds a second, different rule — scope creep into the security + * model, from a bug fix about missing records. + * + * 2. It disarms a revert-provable security proof. `@proof: rls-by-id-write` + * (`packages/qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by + * the `permission.rowLevelSecurity.using` liveness ledger entry) boots a + * fixture whose member can read nothing but has no write policy, and + * asserts the runner reports `rls-hole`. A caller-scoped probe 404s that + * PATCH, so the RED half goes green — and the gate can no longer prove + * it is able to go red. If the #1994 fix were ever reverted, this probe + * would MASK it. Accidentally hardening one path is not worth + * permanently blinding the gate that watches the whole class. + * + * So the probe answers existence only, and authorization stays exactly + * where it was. The one behaviour this adds is the 404 the issue asked for: + * an id that names no row at all. + * + * One place, because two gates need this row — the existence gate and OCC's + * `updated_at` comparison — and issuing a probe each would put two + * round-trips on every PATCH. + */ + private async probeRecord(object: string, id: string): Promise { + return this.engine.findOne(object, { where: { id }, context: { isSystem: true } } as any); + } + + /** + * Optimistic Concurrency Control — the COMPARISON half, over a row the + * caller has already read. Pure: it issues no query of its own, which is + * what lets `updateData` run the gate on its existence probe's result + * rather than re-reading the record (#4435). * - * When the caller passes a non-empty `expectedVersion` token (typically - * the `updated_at` value they read), this fetches the current record - * and compares its `updated_at` against the token. Mismatch → throw - * `ConcurrentUpdateError` which the REST layer maps to 409. + * When the caller passes a non-empty `expectedVersion` token (typically the + * `updated_at` value they read), a mismatch throws `ConcurrentUpdateError`, + * which the REST layer maps to 409. * * Behaviour: * - Empty/missing token → no check (opt-in semantics; existing callers * that haven't yet adopted OCC are unaffected). - * - Record not found → no check; downstream `engine.update` will - * surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not - * treat "missing record" as a concurrency conflict. + * - Record not found → no check. We intentionally do not treat "missing + * record" as a concurrency conflict; `updateData` has already answered + * 404 by this point, and `deleteData` lets the driver report it. * - Record has no `updated_at` field (timestamps disabled) → no check. * Logging would be noisy here; OCC is opt-in and the absence of a * version column is an explicit "this object doesn't support OCC" * signal. */ - private async assertVersionMatch( + private assertVersionOf( object: string, id: string, + current: any, expectedVersion: string | undefined, - context: any - ): Promise { + ): void { const expected = normaliseVersionToken(expectedVersion); if (!expected) return; - const findOpts: any = { where: { id } }; - if (context !== undefined) findOpts.context = context; - const current = await this.engine.findOne(object, findOpts); if (!current) return; const currentVersion = normaliseVersionToken((current as any).updated_at); if (!currentVersion) return; @@ -4726,6 +4992,22 @@ export class ObjectStackProtocolImplementation implements } } + /** + * OCC gate for `deleteData`, which — unlike `updateData` — needs no + * existence probe of its own: the driver's own `delete` return reports + * whether a row matched (#4435). So this still probes ONLY when the caller + * actually opted into OCC, keeping a plain DELETE at zero extra reads. + */ + private async assertVersionMatch( + object: string, + id: string, + expectedVersion: string | undefined, + ): Promise { + if (!normaliseVersionToken(expectedVersion)) return; + const current = await this.probeRecord(object, id); + this.assertVersionOf(object, id, current, expectedVersion); + } + // ========================================== // Global Search (M10.5) // ========================================== @@ -4916,6 +5198,10 @@ export class ObjectStackProtocolImplementation implements // ========================================== async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag + // and the cache entry are keyed by type, so two spellings would cache + // the same item twice and invalidate only one of them. + request = canonicalizeMetaRequestType(request); try { // Delegate to getMetaItem so the customization-overlay read order // (sys_metadata → registry → MetadataService) is honoured here too @@ -5285,7 +5571,16 @@ export class ObjectStackProtocolImplementation implements for (const id of ids) { try { - await this.engine.delete(object, { where: { id }, ...ctxOpt } as any); + // [#4435] Per-row honesty on the bulk path. This discarded the + // driver's return and pushed `success: true` unconditionally, so + // `{"ids":["nonexistent_1"]}` answered `succeeded: 1` — a batch + // of typo'd ids reported every one of them deleted. A caller + // reconciling "which of my 200 ids were real" got a list that + // agreed with whatever it sent. Same `=== false` reading as the + // single-record path: the contract's positive not-found value, + // never an inference from a falsy return. + const deleted = await this.engine.delete(object, { where: { id }, ...ctxOpt } as any); + if (deleted === false) throw recordNotFoundError(object, id); results.push({ id: String(id), success: true }); succeeded++; } catch (err: any) { @@ -5824,10 +6119,21 @@ export class ObjectStackProtocolImplementation implements return true; } - async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null }) { + async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string }) { if (!request.item) { throw new Error('Item data is required'); } + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. + request = canonicalizeMetaRequestType(request); + // What the history row, the audit row and the watch event record as the + // origin of this write. Defaults to this method — the ordinary Studio / + // REST / SDK save. The only caller that overrides it is + // {@link migrateStoredMetadata} (`'migrate-stored'`), so an operator + // reading a diff can tell a canonicalization pass from an author's edit + // (#4327). NOT request-derived: the REST layer builds this request field + // by field and never forwards a client-supplied `source`, so provenance + // stays something the server states, not something a caller claims. + const writeSource = request.source ?? 'protocol.saveMetaItem'; // Drop OUR OWN read decorations before anything reads the body (#4326). // The write path persists verbatim by design (ADR-0005 §Validation), so // the standard Studio round-trip — GET (decorated) → edit → PUT the whole @@ -5890,7 +6196,7 @@ export class ObjectStackProtocolImplementation implements ...(request.organizationId ? { organizationId: request.organizationId } : {}), operation: 'save', ...(request.actor ? { actor: request.actor } : {}), - source: 'protocol.saveMetaItem', + source: writeSource, }); if (lockErr) throw lockErr; } @@ -6148,7 +6454,7 @@ export class ObjectStackProtocolImplementation implements const result = await repo.put(ref, request.item, { parentVersion, actor: request.actor ?? 'system', - source: 'protocol.saveMetaItem', + source: writeSource, intent, state: mode === 'draft' ? 'draft' : 'active', ...(request.packageId !== undefined ? { packageId: request.packageId } : {}), @@ -6171,7 +6477,7 @@ export class ObjectStackProtocolImplementation implements outcome: 'allowed', code: 'ok', ...(request.actor ? { actor: request.actor } : {}), - source: 'protocol.saveMetaItem', + source: writeSource, note: mode === 'draft' ? 'draft' : 'active', }); // [ADR-0094] Awaited projection BEFORE the fire-and-forget @@ -6310,6 +6616,288 @@ export class ObjectStackProtocolImplementation implements } } + /** + * `os migrate meta --stored` — canonicalize `sys_metadata` rows in place so + * the read-path conversion chain has a finish line (#4327). + * + * #4317 made every stored-row rehydration seam replay the full ADR-0087 + * chain, so a row written under any past protocol *reads* canonical forever + * ({@link convertStoredItem}). The rows themselves stayed legacy: the chain + * re-lowers them on every load and each one emits a conversion notice per + * process. This pass ends that for a deployment that runs it — same chain, + * same policy, result written back — while the read path stays the + * guarantee for every deployment that does not (#3855: operator-run + * migrations cannot be relied upon, so nothing here is load-bearing). + * + * ## What it walks + * + * `active` and `draft` rows, every org (the env-wide `organization_id IS + * NULL` bucket included). `archived` / `deprecated` rows are deliberately + * not read: they are not served metadata, and rewriting them would edit a + * record of what *was*. `sys_metadata_history` is untouched for the same + * reason the addendum gives — converting a version body would break the + * checksum↔body pairing. + * + * ## How it writes + * + * Through {@link saveMetaItem}, not the repository directly, so a rewritten + * row gets everything an author's save gets: the schema gate, a + * `sys_metadata_history` row, a fresh checksum, the mutation projectors and + * the watch event Studio's HMR consumes. Three deliberate arguments: + * + * - `parentVersion: row.checksum` — a true optimistic lock. A concurrent + * writer that moved the row between our read and our write gets a 409, + * reported as `failed`, never a clobber. + * - `force: true` — the destructive-change diff compares the *stored* + * body (which `getMetaItem` already serves converted) against the body we + * are about to write (the same conversion). It is empty by construction, + * and there is no author here for a confirmation prompt to reach. + * - `source: 'migrate-stored'` — so a history diff distinguishes a + * canonicalization pass from an edit someone made. + * + * ## What it declines to touch, and says so + * + * - **`flow` rows with no reachable automation engine.** Flow-node + * conversions carry ADR-0078's open-namespace conflict guard, which + * needs the engine's live executor registry. When one is reachable — + * passed as `canonicalizeFlow`, or resolved from the services registry + * (#4498) — flows are migrated like anything else (#4454); when none is, + * they are reported `skipped` with that reason, never counted done. + * - **Types with no repository write path** (neither `allowOrgOverride` nor + * `allowRuntimeCreate`). `saveMetaItem` routes those down the legacy + * raw-engine branch, which records no history and forces `state: + * 'active'` — a historyless rewrite that could also promote a draft is + * not what this pass promises, so it declines instead. + * + * Today that is exactly one type, `agent`, and its skip is **permanent + * by design, not a to-do** (#4507): ADR-0063 §2 closes `*.agent.ts` to + * third parties, so the only agent definitions in existence are the two + * the platform ships from version control — where git, not + * `sys_metadata_history`, is the change log. See the note beside the + * `agent` entry in `metadata-plugin.zod.ts` before treating this branch + * as a gap to close. + * - **Rows that still fail the current schema after conversion.** + * `saveMetaItem` rejects them (422) and that rejection is correct: the + * body is a genuine contract violation, not chain-owned history. They + * surface as `failed` with the validation message, keep reading through + * the chain, and stay fixable in Studio. + */ + async migrateStoredMetadata(request: { + /** Write. Omitted / false = preview: reports what it would do, writes nothing. */ + apply?: boolean; + /** Restrict to these metadata types (singular or plural spelling). */ + types?: string[]; + /** Recorded as the writer on the history + audit rows. */ + actor?: string; + /** + * Canonicalize a stored `flow` body (#4454). **Optional override** — + * when omitted, the automation engine is resolved from the live + * services registry (#4498, {@link resolveFlowCanonicalizer}). + * + * `AutomationEngine.canonicalizeStoredFlow` is the implementation. + * Flow conversions carry ADR-0078's open-namespace conflict guard, + * which needs the engine's executor registry to tell a rename from a + * clobber. A caller running next to a live engine (an admin route, a + * server task) needs to pass nothing; the CLI passes its own because + * it boots an inert engine specifically to hold one, and an explicit + * hook is also what makes the flow branch testable without an engine. + * + * When neither is available — a control-plane or metadata-only host — + * flow rows are reported `skipped` with that reason rather than + * quietly counted done. + * + * Must return the **storable** shape — conversions and the schema's + * `condition` envelopes, without schema defaults. Throwing is a valid + * answer for a row that cannot canonicalize; the row is reported + * `failed` with the message. + */ + canonicalizeFlow?: (name: string, body: unknown) => StoredFlowCanonicalization; + } = {}): Promise { + const canonicalizeFlow = request.canonicalizeFlow ?? this.resolveFlowCanonicalizer(); + const apply = request.apply === true; + const typeFilter = request.types && request.types.length > 0 + ? new Set(request.types.map((t) => PLURAL_TO_SINGULAR[t] ?? t)) + : null; + + const report: StoredMigrationReport = { + apply, + protocol: PROTOCOL_VERSION, + scanned: 0, + canonical: 0, + pending: 0, + rewritten: 0, + skipped: 0, + failed: 0, + rows: [], + }; + + // Two scoped queries rather than one unfiltered scan: `state` is an + // equality column and these are the only two states that are SERVED + // metadata. Archived bodies are never even read. + const rows: any[] = []; + for (const state of ['active', 'draft'] as const) { + rows.push(...await this.engine.find('sys_metadata', { where: { state } })); + } + + for (const row of rows) { + const rawType = String(row.type ?? ''); + const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType; + if (typeFilter && !typeFilter.has(singular)) continue; + report.scanned++; + + const state: 'active' | 'draft' = row.state === 'draft' ? 'draft' : 'active'; + const organizationId: string | null = row.organization_id ?? null; + const packageId: string | null = row.package_id ?? null; + const base = { + id: String(row.id ?? ''), + type: singular, + name: String(row.name ?? ''), + organizationId, + packageId, + state, + notices: [] as StoredMigrationNotice[], + }; + // An already-canonical row is counted, never itemised: on a healthy + // deployment that is every row, and a report listing all of them + // would bury the handful that actually need something. + const record = (entry: StoredMigrationRow): void => { + if (entry.outcome === 'canonical') { + report.canonical++; + return; + } + report[entry.outcome]++; + report.rows.push(entry); + }; + + let body: unknown; + try { + body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + } catch (e: any) { + record({ + ...base, + outcome: 'failed', + reason: `the stored body is not valid JSON (${e?.message ?? String(e)})`, + }); + continue; + } + + // Flow rows need the automation engine's live executor registry for + // ADR-0078's open-namespace conflict guard — supplied by the caller + // (#4454) or resolved from the services registry (#4498), and + // reported `skipped` when neither can reach one. + let flowResult: StoredFlowCanonicalization | undefined; + if (singular === 'flow') { + if (!canonicalizeFlow) { + record({ + ...base, + outcome: 'skipped', + reason: 'flows canonicalize at AutomationEngine.registerFlow — the node-type ' + + 'conflict guard needs the live executor registry, and no automation service ' + + 'is reachable from this caller', + }); + continue; + } + try { + flowResult = canonicalizeFlow(base.name, body); + } catch (e: any) { + // `FlowSchema` is strict (#4001) and the region validator + // hard-fails, so this is a row that cannot register at all — + // already broken at runtime. Report it; never persist a guess. + record({ + ...base, + outcome: 'failed', + reason: `the flow does not canonicalize: ${e?.message ?? String(e)}`, + }); + continue; + } + if (flowResult.conflicts.length > 0) { + // A rename refused because its old token is a LIVE name owned + // by something else. Rewriting would clobber that owner, and + // skipping quietly would hide it — the guard exists to be loud. + const first = flowResult.conflicts[0]!; + record({ + ...base, + outcome: 'failed', + reason: `conversion refused — '${first.token}' at ${first.path} is a live name in ` + + `this environment (${flowResult.conflicts.length} conflict(s)). ${first.message}`, + }); + continue; + } + } + + const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(singular); + const runtimeCreateAllowed = ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singular); + if (!overlayAllowed && !runtimeCreateAllowed) { + record({ + ...base, + outcome: 'skipped', + reason: `type '${singular}' has no repository write path (allowOrgOverride and ` + + 'allowRuntimeCreate are both false), so a rewrite would record no history', + }); + continue; + } + + // A flow's canonical body was already computed above (it needs the + // engine); everything else converts here. + // + // The change signal differs by type, and the difference is real. + // For a non-flow item every rewrite comes from a conversion, and a + // conversion always emits a notice (ADR-0087 D2 "loud"), so notices + // are exact. A flow additionally gains the `{dialect, source}` + // envelope the schema derives for edge conditions — that is a + // schema transform, not a conversion, so it emits NO notice while + // still changing the body. Both passes are copy-on-write, so + // identity is the precise test there: `storable === body` exactly + // when nothing was rewritten at all. + const { item, notices } = flowResult + ? { item: flowResult.storable, notices: flowResult.notices } + : this.convertStoredItemDetailed(singular, body); + const changed = flowResult ? item !== body : notices.length > 0; + if (!changed) { + record({ ...base, outcome: 'canonical' }); + continue; + } + const flattened: StoredMigrationNotice[] = notices.map((n) => ({ + conversionId: n.conversionId, + surface: n.surface, + from: n.from, + to: n.to, + path: n.path, + message: n.message, + })); + + if (!apply) { + record({ ...base, notices: flattened, outcome: 'pending' }); + continue; + } + + try { + await this.saveMetaItem({ + type: singular, + name: base.name, + item, + mode: state === 'draft' ? 'draft' : 'publish', + parentVersion: row.checksum ?? null, + packageId, + force: true, + source: 'migrate-stored', + actor: request.actor ?? 'migrate-stored', + ...(organizationId ? { organizationId } : {}), + }); + record({ ...base, notices: flattened, outcome: 'rewritten' }); + } catch (e: any) { + record({ + ...base, + notices: flattened, + outcome: 'failed', + reason: e?.message ?? String(e), + }); + } + } + + return report; + } + /** * Yield the durable change-log for a single metadata item — every * put/delete recorded in `sys_metadata_history` for `(org, type, name)`, @@ -7309,22 +7897,91 @@ export class ObjectStackProtocolImplementation implements const copied: Array<{ type: string; name: string }> = []; const failed: Array<{ type: string; name: string; error: string }> = []; + // Resolved once for the whole copy: every flow row in this package needs + // the same engine, and a package with fifty flows should not walk the + // service table fifty times. + const canonicalizeFlow = this.resolveFlowCanonicalizer(); + for (const row of rows) { const newName = renameName(row.name); - let item: any; + const rawType = String(row.type); + const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType; + let body: unknown; try { - // Canonicalize the source row before re-saving (#3903): the copy - // is a NEW write and must pass today's schema gate, so a legacy - // shape the chain owns is lifted rather than failing the copy — - // duplication never mints new rows in a pre-protocol dialect. - item = this.convertStoredItem( - String(row.type), - typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {}), - ); + body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {}); } catch { failed.push({ type: row.type, name: row.name, error: 'unparseable metadata' }); continue; } + + // Canonicalize the source row before re-saving (#3903): the copy is + // a NEW write and must pass today's schema gate, so a legacy shape + // the chain owns is lifted rather than failing the copy — + // duplication never mints new rows in a pre-protocol dialect. + // + // For `flow` that guarantee was false until #4498: `convertStoredItem` + // returns flows untouched, and `FlowNodeSchema.config` is an open + // `z.record`, so a pre-17 body (`delete_record` with `config.filters`) + // sailed through `saveMetaItem` and landed verbatim in a brand-new + // row. ADR-0087 justifies the whole stored-metadata design on new + // writes being canonical — "a strictly shrinking concern" — and this + // was the one live producer contradicting it. + let item: any; + if (singular === 'flow') { + if (!canonicalizeFlow) { + // No engine in this process (control-plane / metadata-only + // host). Copy the source body as-is — the honest behaviour, + // and no worse than the source row already is — rather than + // failing a duplication that has nothing to do with flows. + // `os migrate meta --stored --apply` is the finish line for + // both rows, and it reports what it could not canonicalize. + item = body; + } else { + try { + const result = canonicalizeFlow(String(row.name ?? ''), body); + if (result.conflicts.length > 0) { + // ADR-0078's guard refused a node-type rename because + // the old token is a LIVE name owned by something + // else here. Copying the un-renamed body anyway would + // mint exactly the row this fix exists to prevent, so + // the item fails and names the token (same posture as + // #4454's `failed` outcome). + const first = result.conflicts[0]!; + failed.push({ + type: row.type, + name: row.name, + error: `conversion refused — '${first.token}' at ${first.path} is a live name in ` + + `this environment (${result.conflicts.length} conflict(s)). ${first.message}`, + }); + continue; + } + item = result.storable; + } catch (e: any) { + // `FlowSchema` is strict (#4001) and the region validator + // hard-fails: this source row cannot register at all, so + // the copy would be broken the same way. Report it. + failed.push({ + type: row.type, + name: row.name, + error: `the flow does not canonicalize: ${e?.message ?? String(e)}`, + }); + continue; + } + } + } else { + try { + item = this.convertStoredItem(rawType, body); + } catch (e: any) { + // A tombstoned key throws here (ADR-0087 D2) — a genuine + // contract violation in the source, not a parse failure. + failed.push({ + type: row.type, + name: row.name, + error: `the source item does not convert: ${e?.message ?? String(e)}`, + }); + continue; + } + } const rewritten = deepRewrite(item); if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) rewritten.name = newName; try { @@ -7884,6 +8541,12 @@ export class ObjectStackProtocolImplementation implements /** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */ projectionApplied?: MutationProjectionOutcome; }> { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. Without it + // the authorization tier (`isOverlayAllowed` / `isArtifactBacked`) and + // the registry heal (`restoreArtifactRegistryView`) read the caller's + // spelling while the repository deletes under the singular — so a + // DELETE could remove the row and leave the shadow it was meant to lift. + request = canonicalizeMetaRequestType(request); // Two-tier authorization for delete (mirrors saveMetaItem). // • Artifact-backed item → delete becomes a tombstone overlay, // requires `allowOrgOverride`. diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts new file mode 100644 index 0000000000..b71cbe9155 --- /dev/null +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The report shape + renderer for the stored-metadata canonicalization pass + * (#4327), the follow-up to #3903's read-path guarantee. + * + * #4317 closed the correctness gap from the read side: every stored-row + * rehydration seam replays the full ADR-0087 conversion chain + * (`applyConversionsToStoredItem`, retired entries included), so a row written + * under any past protocol is *served* canonical forever. What it deliberately + * did not do is make the rows themselves canonical — a pre-17 row keeps its + * legacy bytes, the chain re-lowers it on every load, and each affected row + * emits one conversion notice per process. + * + * {@link ObjectStackProtocolImplementation.migrateStoredMetadata} is the + * operator-run pass that ends that: same chain, same policy, but the result is + * written back through the normal write path so the row stops carrying the old + * dialect. This module owns the vocabulary that pass reports in, kept beside + * the types rather than in the CLI so an admin route renders the same run the + * same way. + * + * **Not load-bearing.** #3855's conclusion still holds — an operator-run + * migration cannot be relied on, so the read path stays the guarantee and + * nothing gates on this having run. No `sys_migration` flag is recorded for + * exactly that reason: a flag row would advertise a gate that does not exist. + * The verifiable statement operators wanted is the *re-run* — a second pass + * that reports every row canonical is the evidence, and it costs one command. + */ + +/** + * What a caller with a live automation engine hands back for a stored `flow` + * body (#4454) — structurally `AutomationEngine.canonicalizeStoredFlow`'s + * result, declared here so `metadata-protocol` states the contract it consumes + * without depending on the automation service. + * + * `storable` is the shape to PERSIST: conversions plus the `{dialect, source}` + * envelopes the flow schema derives for edge conditions, and deliberately not + * the schema's defaults — persisting a default the author never wrote would pin + * that row to today's value while untouched rows follow tomorrow's. + */ +export interface StoredFlowCanonicalization { + /** The canonical body to write back. Identical (by reference) to the input when nothing changed. */ + storable: unknown; + /** Conversions that fired, in the spec's notice shape. */ + notices: Array<{ + conversionId: string; + surface: string; + from: string; + to: string; + path: string; + message: string; + }>; + /** + * Renames the guard REFUSED because the old token is a live name owned by + * something else. A non-empty list fails the row loudly — rewriting would + * clobber that owner, and skipping quietly would hide it. + */ + conflicts: Array<{ conversionId: string; token: string; path: string; message: string }>; +} + +/** What the pass did with (or would do with) one `sys_metadata` row. */ +export type StoredMigrationOutcome = + /** The chain was a no-op — the row is already on protocol. Not itemised. */ + | 'canonical' + /** Preview: the chain would rewrite this row. Nothing was written. */ + | 'pending' + /** Apply: the canonical body was re-saved through the write path. */ + | 'rewritten' + /** Outside this pass's reach — see {@link StoredMigrationRow.reason}. */ + | 'skipped' + /** The row could not be read, or the re-save was refused. */ + | 'failed'; + +/** + * One conversion the chain applied to a row — the per-row detail a preview + * run prints, flattened from the spec's {@link ConversionNotice} to the + * fields an operator acts on. + */ +export interface StoredMigrationNotice { + /** The `MetadataConversion.id` that fired, e.g. `flow-node-crud-filter-alias`. */ + conversionId: string; + /** Dotted surface the conversion governs, e.g. `object.field.conditionalRequired`. */ + surface: string; + /** The off-spec token/shape found in the stored body. */ + from: string; + /** The canonical token/shape it was lowered to. */ + to: string; + /** Where in the item it applied, e.g. `objects[0].fields.amount`. */ + path: string; + /** The chain's own human-facing line. */ + message: string; +} + +/** Per-row result. Rows the chain left alone (`canonical`) are counted, not listed. */ +export interface StoredMigrationRow { + /** `sys_metadata.id` — the row this is about, so an operator can go look at it. */ + id: string; + /** Singular metadata type (`object`, `view`, …), normalized from the row's spelling. */ + type: string; + name: string; + /** `null` = the env-wide overlay bucket. */ + organizationId: string | null; + /** `null` = a package-less (global) overlay row. */ + packageId: string | null; + state: 'active' | 'draft'; + outcome: StoredMigrationOutcome; + /** The conversions this row carries. Empty unless the chain rewrote something. */ + notices: StoredMigrationNotice[]; + /** Why a `skipped` / `failed` row was not rewritten. Absent otherwise. */ + reason?: string; +} + +/** The whole run. `apply: false` is a preview — it writes nothing, by construction. */ +export interface StoredMigrationReport { + /** False = preview. A preview never writes, not even a row it would leave identical. */ + apply: boolean; + /** The protocol version the chain canonicalized *to* — what a clean run attests. */ + protocol: string; + /** Rows examined (after any `--type` filter; archived/deprecated rows are not read). */ + scanned: number; + /** Rows the chain left unchanged — already on protocol. */ + canonical: number; + /** Preview only: rows the chain would rewrite. Always 0 on an apply run. */ + pending: number; + /** Apply only: rows re-saved through the write path. */ + rewritten: number; + skipped: number; + failed: number; + /** Every row that is not `canonical`, in scan order. */ + rows: StoredMigrationRow[]; +} + +/** + * Is this deployment's stored metadata on protocol? + * + * True when nothing is left to convert and nothing was refused. `skipped` rows + * deliberately do NOT count against it: they name a seam that owns them + * (flows canonicalize at `AutomationEngine.registerFlow`) or a type this pass + * has no history-recording write path for — both are documented carve-outs, not + * work this command left half-done. They are still printed, so the operator + * sees what the verdict does not cover. + */ +export function storedMigrationClean(report: StoredMigrationReport): boolean { + return report.pending === 0 && report.failed === 0; +} + +/** Render a run for a terminal. One line per non-canonical row, notices nested. */ +export function formatStoredMigrationReport(report: StoredMigrationReport): string[] { + const lines: string[] = []; + lines.push( + `Examined ${report.scanned} stored metadata row(s) (active + draft, all orgs) ` + + `against the protocol ${report.protocol} conversion chain.`, + ); + + const converting = report.rows.filter((r) => r.outcome === 'pending' || r.outcome === 'rewritten'); + if (converting.length > 0) { + lines.push( + report.apply + ? `✓ Rewrote ${report.rewritten} row(s) carrying a pre-protocol shape:` + : `→ ${report.pending} row(s) carry a pre-protocol shape and would be rewritten:`, + ); + for (const row of converting) { + lines.push(` • ${row.type}/${row.name} ${describeScope(row)}`); + for (const n of row.notices) { + lines.push(` ${n.conversionId}: ${n.from} → ${n.to} at ${n.path}`); + } + } + } + + const skipped = report.rows.filter((r) => r.outcome === 'skipped'); + if (skipped.length > 0) { + lines.push(`⚠ ${skipped.length} row(s) are outside this pass — they keep reading through the chain:`); + for (const row of skipped) { + lines.push(` • ${row.type}/${row.name} ${describeScope(row)} — ${row.reason ?? 'skipped'}`); + } + } + + const failed = report.rows.filter((r) => r.outcome === 'failed'); + if (failed.length > 0) { + lines.push(`✗ ${failed.length} row(s) could not be rewritten:`); + for (const row of failed) { + lines.push(` • ${row.type}/${row.name} ${describeScope(row)} — ${row.reason ?? 'failed'}`); + } + } + + if (report.scanned === 0) { + // "Nothing to convert" and "nothing was looked at" are different claims, + // and only the first is a pass. A run pointed at the wrong project — the + // database line above names which one — would otherwise read as clean. + lines.push( + '⚠ No rows were examined, so this run attests nothing. An empty result is what ' + + 'a deployment that has never authored metadata looks like, and also what running ' + + 'from the wrong project root looks like — check the database named above.', + ); + } else if (converting.length === 0 && failed.length === 0) { + lines.push( + `✓ Every row examined is already on protocol ${report.protocol} — ` + + 'the read-path conversion pass is a no-op here.', + ); + } + return lines; +} + +/** `[org=… package=… draft]` — only the parts that are not the default. */ +function describeScope(row: StoredMigrationRow): string { + const parts: string[] = []; + parts.push(row.organizationId ? `org=${row.organizationId}` : 'env-wide'); + if (row.packageId) parts.push(`package=${row.packageId}`); + if (row.state === 'draft') parts.push('draft'); + return `[${parts.join(', ')}]`; +} diff --git a/packages/objectql/src/datasource-mapping.test.ts b/packages/objectql/src/datasource-mapping.test.ts index 11598ab7ff..446d49f41d 100644 --- a/packages/objectql/src/datasource-mapping.test.ts +++ b/packages/objectql/src/datasource-mapping.test.ts @@ -177,4 +177,91 @@ describe('DatasourceMapping', () => { const result = await engine.insert('account', { name: 'Test' }); expect(result).toBeDefined(); }); + + // ── #4462: a matched mapping rule is routing, not a hint ────────────── + + describe('a mapped datasource with no live driver never falls through (#4462)', () => { + /** + * The defect this pins: `getDriver` step 2 read + * `mapped && this.drivers.has(mapped)`, so a mapping rule naming a + * datasource that failed to connect (or was never connected at all) fell + * silently to step 5 and the object's rows went to the DEFAULT store. + * Boot succeeded, `/ready` answered 200, the datasource name appeared in + * zero log lines, and the write returned 201 — the operator found out by + * looking in the database they declared and finding it empty. + */ + const registerTask = () => + engine.registry.registerObject( + { name: 'rc1_audit', fields: { title: { type: 'text' } } }, + 'com.example.probe', + 'probe', + 'own', + ); + + it('a write to a mapped-but-unconnected datasource fails loudly instead of hitting default', async () => { + const defaultDriver = createMockDriver('sqlite'); + engine.registerDriver(defaultDriver, true); + engine.setDatasourceMapping([{ objectPattern: 'rc1_audit', datasource: 'broken' }]); + registerTask(); + + await expect(engine.insert('rc1_audit', { title: 'ds-probe' })).rejects.toThrow( + /Datasource 'broken' mapped for object 'rc1_audit' is not registered/, + ); + await expect(engine.find('rc1_audit', {})).rejects.toThrow(/'broken'/); + }); + + it('when the connect verdict is known, the error says WHY rather than "not registered"', async () => { + engine.registerDriver(createMockDriver('sqlite'), true); + engine.setDatasourceMapping([{ objectPattern: 'rc1_*', datasource: 'broken' }]); + registerTask(); + // What `DatasourceConnectionService.recordState` reports after a failed + // boot connect under OS_ALLOW_DRIVER_CONNECT_FAILURE. + engine.markDatasourceUnavailable({ + name: 'broken', + kind: 'failed', + publicDetail: 'analytics database unreachable', + }); + + await expect(engine.insert('rc1_audit', { title: 'x' })).rejects.toThrow( + /analytics database unreachable|ERR_DATASOURCE_UNAVAILABLE|broken/, + ); + }); + + it('a mapping to `default` still resolves — the default driver keeps its natural name', async () => { + // #3826: the default is registered under `sqlite`/`memory`, never under + // the literal `default`, so `drivers.has('default')` is false by + // construction and step 5 is how routing to it works. Turning the + // fall-through into a throw must not break that. + engine.registerDriver(createMockDriver('sqlite'), true); + engine.setDatasourceMapping([{ default: true, datasource: 'default' }]); + registerTask(); + + await expect(engine.insert('rc1_audit', { title: 'ok' })).resolves.toBeDefined(); + }); + + it('an unmatched mapping leaves an object on the default store', async () => { + // The rule set is not a claim about EVERY object — only the ones it + // matches. An object no rule names keeps its old resolution. + engine.registerDriver(createMockDriver('sqlite'), true); + engine.setDatasourceMapping([{ objectPattern: 'other_*', datasource: 'broken' }]); + registerTask(); + + await expect(engine.insert('rc1_audit', { title: 'ok' })).resolves.toBeDefined(); + }); + + it('resolveMappedDatasource is the one matcher the boot path may ask', async () => { + // The boot gate (`isDatasourceAddressed` (d)) must learn which objects a + // rule routes from the SAME resolver routing uses. Two matchers drifting + // by one clause is how you get a datasource connected that routing never + // uses, or routed to and never connected — the defect itself. + engine.setDatasourceMapping([ + { objectPattern: 'rc1_*', datasource: 'broken' }, + { default: true, datasource: 'default' }, + ]); + registerTask(); + + expect(engine.resolveMappedDatasource('rc1_audit')).toBe('broken'); + expect(engine.resolveMappedDatasource('unrelated_object')).toBe('default'); + }); + }); }); diff --git a/packages/objectql/src/engine-audit-anchor-write.test.ts b/packages/objectql/src/engine-audit-anchor-write.test.ts new file mode 100644 index 0000000000..95515f9dca --- /dev/null +++ b/packages/objectql/src/engine-audit-anchor-write.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4447] `created_at` is engine-owned: a client-supplied value on an ordinary + * write is DROPPED, not persisted. + * + * Reproduction harness: a REAL {@link ObjectQL} engine over a minimal in-memory + * driver whose `update` lets incoming data win (`{...cur, ...data}`) — the same + * shape driver-sql has, which is why a value that survives the engine's strip + * reaches the row. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +const taskObject = { + name: 'audit_task', + label: 'Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + progress: { name: 'progress', label: 'Progress', type: 'number' as const }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + findStream() { throw new Error('not implemented'); }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + // driver-sql's `stampInsertTimestamps`: fills the audit timestamps ONLY + // when absent, so a supplied `created_at` survives the driver. That is + // why the insert-side protection has to be the engine's strip. + const row: Record = { ...data, id }; + const iso = new Date(Date.now() - 86_400_000).toISOString(); + if (row.created_at == null) row.created_at = iso; + if (row.updated_at == null) row.updated_at = iso; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) return null; + // driver-sql's `update`: incoming data wins, and it force-advances + // `updated_at` but never touches `created_at`. So anything the engine + // failed to strip off `created_at` lands on the row — which is exactly + // why `updated_at` LOOKED protected while the anchor did not. + const updated = { ...cur, ...data, id, updated_at: new Date().toISOString() }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + for (const r of rows) storeFor(object).set(r.id as string, { ...r, ...data, id: r.id }); + return rows.length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +const FORGED = '1999-01-01T00:00:00.000Z'; + +describe('[#4447] created_at is engine-owned on an ordinary write', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(taskObject as any); + }); + + /** An ordinary authenticated caller — NOT `isSystem`, no `preserveAudit`. */ + const userCtx = { userId: 'u1' }; + + async function seed() { + return engine.insert('audit_task', { title: 'T', progress: 1 }, { context: userCtx } as any); + } + + it('the injected field metadata marks it read-only and system', () => { + // The premise: if this is ever `readonly: false`, the strip has nothing to + // key off and every assertion below becomes vacuous. + const schema: any = engine.registry.getObject('audit_task'); + expect(schema.fields.created_at).toMatchObject({ readonly: true, system: true }); + }); + + it('a client-supplied created_at on UPDATE is dropped, not persisted', async () => { + const row: any = await seed(); + const realCreatedAt = row.created_at; + expect(realCreatedAt).toBeTruthy(); + + await engine.update( + 'audit_task', + { progress: 42, created_at: FORGED }, + { where: { id: row.id }, context: userCtx } as any, + ); + + const after: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + // The legitimate half of the same request still lands. + expect(after.progress).toBe(42); + // The audit anchor does not move. + expect(after.created_at).toBe(realCreatedAt); + expect(after.created_at).not.toBe(FORGED); + }); + + it('reports the drop through onFieldsDropped, so the caller is not left guessing', async () => { + const row: any = await seed(); + const dropped: any[] = []; + await engine.update( + 'audit_task', + { progress: 7, created_at: FORGED }, + { + where: { id: row.id }, + context: userCtx, + onFieldsDropped: (e: any) => dropped.push(e), + } as any, + ); + // #3794's `droppedFields` contract: this is exactly the case the key exists + // for, and it had no live producer on the audit trio before this fix. + expect(dropped.flatMap((e) => e.fields)).toContain('created_at'); + }); + + it('its two siblings behave the same way — one posture, not three', async () => { + const row: any = await seed(); + const before: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + + await engine.update( + 'audit_task', + { progress: 3, created_at: FORGED, created_by: 'forged_user', updated_by: 'forged_user' }, + { where: { id: row.id }, context: userCtx } as any, + ); + + const after: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + expect(after.created_at).toBe(before.created_at); + expect(after.created_by).toBe(before.created_by); + expect(after.updated_by).not.toBe('forged_user'); + }); + + + it('a bulk update cannot forge it either — the call site, not just the switch', async () => { + // AGENTS.md PD #10's lesson: a guard wired into single-id writes only is + // still a hole one call site over. + const row: any = await seed(); + const before: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + + await engine.update( + 'audit_task', + { progress: 9, created_at: FORGED }, + { where: { progress: 1 }, multi: true, context: userCtx } as any, + ); + + const after: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + expect(after.created_at).toBe(before.created_at); + }); + + it('the historical-import path may still reinstate it (preserveAudit)', async () => { + // The deliberate escape hatch #3479/#3493 argues for. It must keep working: + // this fix closes the ORDINARY write path, it does not remove back-dating. + const row: any = await seed(); + await engine.update( + 'audit_task', + { created_at: FORGED }, + { where: { id: row.id }, context: { ...userCtx, preserveAudit: true } } as any, + ); + const after: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + expect(after.created_at).toBe(FORGED); + }); + + it('a system-context write is still exempt', async () => { + const row: any = await seed(); + await engine.update( + 'audit_task', + { created_at: FORGED }, + { where: { id: row.id }, context: { isSystem: true } } as any, + ); + const after: any = await engine.findOne('audit_task', { where: { id: row.id } } as any); + expect(after.created_at).toBe(FORGED); + }); +}); + +// --------------------------------------------------------------------------- +// The ROOT CAUSE. `showcase_task` never declares `created_at` in source, yet +// the built app artifact ships one: +// +// "created_at": {"label":"Created At","type":"datetime","readonly":false, …} +// +// i.e. a materialized field carrying only FieldSchema DEFAULTS. Because the +// object then HAS the field, `applySystemFields` skipped injecting +// `AUDIT_FIELD_DEFS.created_at` (`readonly: true`) and the author-wins merge +// let the default-valued one through — so `stripReadonlyFields` had nothing to +// key off and a forged value went straight to the row. +// --------------------------------------------------------------------------- +describe('[#4447] a declared audit field cannot loosen the platform posture', () => { + const shadowed = { + name: 'audit_shadow', + label: 'Shadowed', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + // Verbatim from examples/app-showcase/dist/objectstack.json. + created_at: { + label: 'Created At', type: 'datetime' as const, required: false, + searchable: false, multiple: false, unique: false, + deleteBehavior: 'set_null' as const, hidden: false, + readonly: false, sortable: true, externalId: false, + }, + }, + }; + + let engine: ObjectQL; + beforeEach(async () => { + engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(shadowed as any); + }); + + it('the registry restores the engine-owned governance', () => { + const schema: any = engine.registry.getObject('audit_shadow'); + expect(schema.fields.created_at).toMatchObject({ readonly: true, system: true }); + // …without discarding what the author legitimately set. + expect(schema.fields.created_at.label).toBe('Created At'); + expect(schema.fields.created_at.sortable).toBe(true); + }); + + it('and the forged PATCH no longer lands — the issue\'s exact repro', async () => { + const row: any = await engine.insert( + 'audit_shadow', { title: 'T' }, { context: { userId: 'u1' } } as any, + ); + const real = row.created_at; + await engine.update( + 'audit_shadow', + { title: 'T2', created_at: FORGED }, + { where: { id: row.id }, context: { userId: 'u1' } } as any, + ); + const after: any = await engine.findOne('audit_shadow', { where: { id: row.id } } as any); + expect(after.title).toBe('T2'); + expect(after.created_at).toBe(real); + }); + + it('an author keeps every non-governance key they declared', () => { + engine.registry.registerObject({ + name: 'audit_labelled', + label: 'Labelled', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + created_at: { + label: '建档时间', type: 'datetime' as const, + description: 'When the file was opened', hidden: true, group: 'meta', + }, + }, + } as any); + const f: any = engine.registry.getObject('audit_labelled').fields.created_at; + expect(f).toMatchObject({ + label: '建档时间', + description: 'When the file was opened', + hidden: true, + group: 'meta', + readonly: true, + system: true, + }); + }); +}); diff --git a/packages/objectql/src/engine-findone-contract.test.ts b/packages/objectql/src/engine-findone-contract.test.ts new file mode 100644 index 0000000000..efc51ff604 --- /dev/null +++ b/packages/objectql/src/engine-findone-contract.test.ts @@ -0,0 +1,399 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4419 — `findOne` executes every option it declares, and refuses a query that + * selects no particular record. + * + * The reported failure: a read query carrying a predicate the engine does not + * execute is not rejected and not reported; the key is dropped and the query + * runs WITHOUT a predicate. On `findOne` the forced `limit: 1` then turns that + * into the object's FIRST ROW — a real, plausible-looking record unrelated to + * the request, which no caller's `if (!row)` can catch and which propagates + * into whatever is computed next. Downstream, one wrong key defaulted line-item + * prices from the first product in the catalog and evaluated "is this deal + * already closed?" against an unrelated record. + * + * `filter` — the key the issue was reported against — was closed by #4346 (fold + * on every entry point) and #4400 (unknown keys throw), both pinned in + * `engine-filter-alias.test.ts` / `engine-unknown-option.test.ts`. This suite + * covers what those left standing: + * + * 1. **`search` was the same bug under a different key.** `find()` expanded + * ADR-0061 `search` into `where`; `findOne()` did not — while both are + * checked against the SAME legal-key set, so `search` passed the gate, rode + * onto the AST, and reached a driver. No driver reads `ast.search`. So + * `findOne({ search })` returned the first row of the whole object. + * 2. **The empty predicate itself.** Even with every key folded and every + * unknown key refused, `findOne({})` still answered with an arbitrary row. + * It now throws, and names the three ways to be specific. + * 3. **The drift pin at the bottom** is the part that stops (1) recurring: it + * walks `ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to + * have an observable effect. `search` sat declared-but-unexecuted through + * two rounds of hardening because nothing asked that question. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL, ENGINE_OPTION_KEY_SETS } from './engine.js'; + +const account = { + name: 'crm_account', + label: 'Account', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + industry: { name: 'industry', type: 'text' as const }, + annual_revenue: { name: 'annual_revenue', type: 'number' as const }, + owner: { name: 'owner', type: 'lookup' as const, reference: 'person' }, + }, +}; +const person = { + name: 'person', + label: 'Person', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + }, +}; +interface SeenRead { ast: any; opts: any } + +/** Memory driver recording the AST and driver options of every read. */ +function makeRecordingDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + const reads: SeenRead[] = []; + let nextId = 0; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && '$contains' in (v as any)) { + const needle = String((v as any).$contains).toLowerCase(); + if (!String(row[k] ?? '').toLowerCase().includes(needle)) return false; + continue; + } + const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const run = (o: string, ast: any) => { + let rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const ord = Array.isArray(ast?.orderBy) ? ast.orderBy : []; + if (ord.length > 0) { + rows = [...rows].sort((a: any, b: any) => { + for (const { field, order } of ord) { + const cmp = String(a?.[field] ?? '').localeCompare(String(b?.[field] ?? '')); + if (cmp !== 0) return order === 'desc' ? -cmp : cmp; + } + return 0; + }); + } + if (typeof ast?.offset === 'number' && ast.offset > 0) rows = rows.slice(ast.offset); + if (Array.isArray(ast?.fields) && ast.fields.length > 0) { + rows = rows.map((r) => Object.fromEntries(ast.fields.map((f: string) => [f, (r as any)[f]]))); + } + return typeof ast?.limit === 'number' && ast.limit > 0 ? rows.slice(0, ast.limit) : rows; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any, opts: any) { reads.push({ ast, opts }); return run(o, ast); }, + findStream() { throw new Error('ns'); }, + async findOne(o: string, ast: any, opts: any) { reads.push({ ast, opts }); return run(o, ast)[0] ?? null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return run(o, ast).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, stores, reads }; +} + +describe('findOne executes what it declares and refuses an empty predicate (#4419)', () => { + let engine: ObjectQL; + let reads: SeenRead[]; + let one: any, two: any, three: any; + + beforeEach(async () => { + engine = new ObjectQL(); + const mem = makeRecordingDriver(); + reads = mem.reads; + engine.registerDriver(mem.driver, true); + await engine.init(); + engine.registry.registerObject(account as any); + engine.registry.registerObject(person as any); + // The issue's own repro set. + one = await engine.insert('crm_account', { name: 'One', industry: 'Retail', annual_revenue: 100 }); + two = await engine.insert('crm_account', { name: 'Two', industry: 'Metals', annual_revenue: 200 }); + three = await engine.insert('crm_account', { name: 'Three', industry: 'Mining', annual_revenue: 200 }); + reads.length = 0; // drop insert-path reads; the pins below own this log + }); + + const lastRead = () => reads.at(-1)!; + + // ── (1) `search` is a predicate on findOne, not a dropped key ──────── + + it('findOne({search}) matches the searched record, not the first row', async () => { + const row = await engine.findOne('crm_account', { search: 'Two' } as any); + expect(row?.name).toBe('Two'); + expect(row?.id).not.toBe(one.id); + }); + + it('the search term reaches the driver as a $contains predicate — `search` never does', async () => { + await engine.findOne('crm_account', { search: 'Two' } as any); + const { ast } = lastRead(); + expect(ast.where).toBeTruthy(); + expect(JSON.stringify(ast.where)).toContain('$contains'); + expect(ast.search).toBeUndefined(); + expect(ast.searchFields).toBeUndefined(); + }); + + it('findOne({search, searchFields}) narrows the scanned fields, as find() does', async () => { + // 'Two' lives in `name`, 'Metals' in `industry`. Narrowed to + // `industry`, only the latter can hit — and a narrowed miss must be a + // miss, not a fall-back to an unpredicated read. + const narrowed = { searchFields: ['industry'] }; + expect(await engine.findOne('crm_account', { search: 'Two', ...narrowed } as any)).toBeNull(); + expect((await engine.findOne('crm_account', { search: 'Metals', ...narrowed } as any))?.id) + .toBe(two.id); + }); + + it('find({search}) is unchanged — the expansion moved, it did not fork', async () => { + const rows = await engine.find('crm_account', { search: 'Two' } as any); + expect(rows.map((r: any) => r.name)).toEqual(['Two']); + }); + + it('a search that expands to NO filter is refused, not answered with an arbitrary row', async () => { + // A blank/whitespace term expands to nothing (`expandSearchToFilter` + // returns null), so the AST is left with no predicate at all — the + // "predicate resolved to empty" shape the issue names. Before #4419 the + // forced `limit: 1` turned it into the object's first row. + for (const term of ['', ' ']) { + await expect(engine.findOne('crm_account', { search: term } as any)) + .rejects.toThrow(/selects no particular record/); + } + expect(reads).toHaveLength(0); + }); + + // ── (2) the empty-predicate guard ─────────────────────────────────── + + it.each([ + ['no argument at all', undefined], + ['an empty bag', {}], + ['an empty where', { where: {} }], + ['an explicitly null where', { where: null }], + ['a withdrawn filter alias', { filter: null }], + ['projection without a predicate', { fields: ['id', 'name'] }], + ['expand without a predicate', { expand: { owner: { object: 'person' } } }], + ])('findOne with %s throws instead of returning the first row', async (_label, query) => { + await expect(engine.findOne('crm_account', query as any)).rejects.toThrow( + /findOne\('crm_account'\) selects no particular record/, + ); + expect(reads).toHaveLength(0); // refused before the driver was asked + }); + + it('the refusal names all three ways to be specific', async () => { + await expect(engine.findOne('crm_account', {} as any)).rejects.toThrow( + /Pass 'where'.*pass 'orderBy'.*find\('crm_account', \{ limit: 1 \}\)/s, + ); + }); + + it.each([ + ['where', { where: { id: () => two.id } }], + ['the filter alias', { filter: { id: () => two.id } }], + ])('findOne({%s}) still selects the record', async (_label, shape) => { + const key = Object.keys(shape)[0]; + const row = await engine.findOne('crm_account', { [key]: { id: two.id } } as any); + expect(row?.id).toBe(two.id); + }); + + it('orderBy alone is a legitimate findOne — "the first record in THIS order"', async () => { + const row = await engine.findOne('crm_account', { + orderBy: [{ field: 'name', order: 'desc' }], + } as any); + expect(row?.name).toBe('Two'); // Two > Three > One + }); + + it('an empty orderBy array is not an ordering, and does not satisfy the guard', async () => { + await expect(engine.findOne('crm_account', { orderBy: [] } as any)) + .rejects.toThrow(/selects no particular record/); + }); + + it('find() is NOT guarded — returning every row is an honest answer', async () => { + const rows = await engine.find('crm_account'); + expect(rows).toHaveLength(3); + expect(await engine.count('crm_account')).toBe(3); + }); + + it('a non-object where (an expression tree) is the driver\'s to interpret, not refused', async () => { + // The guard closes match-everything, not everything it cannot prove. + await expect(engine.findOne('crm_account', { where: [['name', '=', 'Two']] } as any)) + .resolves.not.toThrow(); + }); + + it('the guard reads the CALLER\'s predicate, before any middleware scoping', async () => { + // A read filter injected downstream narrows which rows are visible; it + // does not make "whichever comes first" something the caller asked for. + engine.registerMiddleware(async (opCtx: any, next: any) => { + if (opCtx.operation === 'findOne') { + opCtx.ast.where = { ...(opCtx.ast.where ?? {}), annual_revenue: 200 }; + } + return next(); + }); + await expect(engine.findOne('crm_account', {} as any)) + .rejects.toThrow(/selects no particular record/); + }); + + // ── the L2 hook surface the issue was found on ────────────────────── + + it('ctx.api.object(o).findOne({where}) works; the same call with no predicate throws', async () => { + const repo = engine.createContext({ userId: 'usr_1' }).object('crm_account'); + expect((await repo.findOne({ where: { id: two.id } }))?.id).toBe(two.id); + expect((await repo.findOne({ search: 'Three' }))?.name).toBe('Three'); + await expect(repo.findOne()).rejects.toThrow(/selects no particular record/); + await expect(repo.findOne({})).rejects.toThrow(/selects no particular record/); + }); + + it('a miss is still null — the guard did not turn "not found" into an error', async () => { + expect(await engine.findOne('crm_account', { where: { id: 'nope' } } as any)).toBeNull(); + expect(await engine.findOne('crm_account', { search: 'nope' } as any)).toBeNull(); + }); + + // ── (3) drift pin: every declared findOne option is executed ──────── + + /** + * One proof per key in `ENGINE_OPTION_KEY_SETS.findOne`: the call to make, + * and what must be observable afterwards. `na` records a key that is + * deliberately not executed, with the reason — the only honest way to leave + * one out, and the thing `search` never had. + * + * The table is asserted to cover the legal set EXACTLY, so a key added to + * the spec must be given a proof (or an explicit `na`) before it can ship. + */ + type Proof = + | { call: Record; expect: (seen: SeenRead) => void } + | { na: string }; + + const PROOFS: Record = { + where: { + call: { where: { name: 'Two' } }, + expect: ({ ast }) => expect(ast.where).toMatchObject({ name: 'Two' }), + }, + fields: { + call: { where: { name: 'Two' }, fields: ['id', 'name'] }, + expect: ({ ast }) => expect(ast.fields).toEqual(['id', 'name']), + }, + orderBy: { + call: { orderBy: [{ field: 'name', order: 'desc' }] }, + expect: ({ ast }) => expect(ast.orderBy).toEqual([{ field: 'name', order: 'desc' }]), + }, + offset: { + call: { where: { annual_revenue: 200 }, offset: 1 }, + expect: ({ ast }) => expect(ast.offset).toBe(1), + }, + search: { + call: { search: 'Two' }, + expect: ({ ast }) => { + expect(ast.search).toBeUndefined(); + expect(JSON.stringify(ast.where)).toContain('$contains'); + }, + }, + searchFields: { + call: { search: 'Two', searchFields: ['name'] }, + expect: ({ ast }) => { + expect(ast.searchFields).toBeUndefined(); + // Narrowed to the one requested field, not the auto-default set. + expect(JSON.stringify(ast.where)).toContain('name'); + }, + }, + expand: { + call: { where: { name: 'Two' }, expand: { owner: { object: 'person' } } }, + // Engine-side post-processing: the relation is resolved after the + // fetch, so the proof is that the key survives onto the AST for it. + expect: ({ ast }) => expect(ast.expand).toBeTruthy(), + }, + context: { + call: { where: { name: 'Two' }, context: { tenantId: 't-1' } }, + expect: ({ ast, opts }) => { + expect(ast.context).toBeUndefined(); // not a driver concern + expect(opts).toMatchObject({ tenantId: 't-1' }); + }, + }, + tenantId: { + call: { where: { name: 'Two' }, tenantId: 't-explicit' }, + expect: ({ opts }) => expect(opts).toMatchObject({ tenantId: 't-explicit' }), + }, + tenantIds: { + call: { where: { name: 'Two' }, tenantIds: ['t-1', 't-2'] }, + expect: ({ opts }) => expect(opts).toMatchObject({ tenantIds: ['t-1', 't-2'] }), + }, + timezone: { + call: { where: { name: 'Two' }, timezone: 'Asia/Shanghai' }, + expect: ({ opts }) => expect(opts).toMatchObject({ timezone: 'Asia/Shanghai' }), + }, + transaction: { + call: { where: { name: 'Two' }, transaction: { id: 'tx-1' } }, + expect: ({ opts }) => expect(opts).toMatchObject({ transaction: { id: 'tx-1' } }), + }, + bypassTenantAudit: { + call: { where: { name: 'Two' }, bypassTenantAudit: true }, + expect: ({ opts }) => expect(opts).toMatchObject({ bypassTenantAudit: true }), + }, + preserveAudit: { + call: { where: { name: 'Two' }, preserveAudit: true }, + expect: ({ opts }) => expect(opts).toMatchObject({ preserveAudit: true }), + }, + limit: { + na: 'findOne is single-row by contract — the literal `limit: 1` on the AST ' + + 'wins over any caller value (and over the folded `top` alias). Legal ' + + 'because the shared find/findOne schema declares it; overridden, not dropped.', + }, + }; + + it('every option findOne declares is one it executes', async () => { + const legal = ENGINE_OPTION_KEY_SETS.findOne; + expect(new Set(Object.keys(PROOFS)), 'PROOFS must cover the legal set exactly') + .toEqual(new Set(legal)); + + for (const [key, proof] of Object.entries(PROOFS)) { + if ('na' in proof) { + expect(proof.na.length, `${key} must state WHY it is not executed`).toBeGreaterThan(20); + continue; + } + reads.length = 0; + await engine.findOne('crm_account', proof.call as any); + expect(reads.length, `findOne({${key}}) must reach the driver`).toBeGreaterThan(0); + proof.expect(lastRead()); + } + }); + + it('the caller-supplied limit is overridden, not honoured — findOne stays single-row', async () => { + await engine.findOne('crm_account', { where: { annual_revenue: 200 }, limit: 5 } as any); + expect(lastRead().ast.limit).toBe(1); + // `top` folds to `limit` (#4346) and loses to the same override. + await engine.findOne('crm_account', { where: { annual_revenue: 200 }, top: 5 } as any); + expect(lastRead().ast.limit).toBe(1); + }); + + it('the issue\'s original repro table, end to end', async () => { + const o = () => engine.createContext({ isSystem: true }).object('crm_account'); + expect((await o().findOne({ where: { id: two.id } }))?.name).toBe('Two'); + expect((await o().findOne({ filter: { id: two.id } }))?.name).toBe('Two'); // #4346 + expect(await o().findOne({ where: { id: 'nope' } })).toBeNull(); + expect(await o().findOne({ filter: { id: 'nope' } })).toBeNull(); // #4346 + expect(await o().count({ where: { annual_revenue: 100 } })).toBe(1); + expect(await o().count({ filter: { annual_revenue: 100 } })).toBe(1); // #4346 + expect((await o().find({ filter: { annual_revenue: 200 } })).map((r: any) => r.id).sort()) + .toEqual([two.id, three.id].sort()); + expect((await o().findOne({ search: 'Two' }))?.name).toBe('Two'); // #4419 + await expect(o().findOne({})).rejects.toThrow(/selects no particular record/); // #4419 + }); +}); diff --git a/packages/objectql/src/engine-lookup-referential-integrity.test.ts b/packages/objectql/src/engine-lookup-referential-integrity.test.ts new file mode 100644 index 0000000000..81bf1721da --- /dev/null +++ b/packages/objectql/src/engine-lookup-referential-integrity.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4441] A `lookup` must not accept an id that exists in no row of the object + * it declares. + * + * The field metadata is unambiguous — + * `{"type":"lookup","required":true,"reference":"sys_permission_set", + * "deleteBehavior":"set_null"}` — and `deleteBehavior` shows the platform + * already reasons about this edge on the DELETE side. The insert side never + * checked, so both of these created a row: + * + * POST /data/sys_position_permission_set + * {"position_id":"…","permission_set_id":"ps_does_not_exist_at_all"} → 200 + * POST /data/showcase_task + * {"title":"…","project":"proj_does_not_exist","status":"backlog"} → 200 + * + * On the RBAC link tables a dangling row is a security-surface record that + * resolves to nothing: the audience-anchor gate has to resolve that permission + * set to evaluate the grant, so the binding is an unevaluable gate input. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +const permissionSet = { + name: 'ref_permission_set', + label: 'Permission Set', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +// The RBAC link-table shape: a required lookup that an audience gate must +// resolve. +const binding = { + name: 'ref_position_permission_set', + label: 'Binding', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + permission_set_id: { + name: 'permission_set_id', label: 'Permission Set', + type: 'lookup' as const, reference: 'ref_permission_set', + required: true, deleteBehavior: 'set_null' as const, + }, + note: { name: 'note', label: 'Note', type: 'text' as const }, + }, +}; + +// An ordinary application object with an OPTIONAL lookup, to prove the rule is +// about resolvability rather than about `required`. +const task = { + name: 'ref_task', + label: 'Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + project: { + name: 'project', label: 'Project', + type: 'lookup' as const, reference: 'ref_permission_set', + }, + tags: { + name: 'tags', label: 'Tags', + type: 'lookup' as const, reference: 'ref_permission_set', multiple: true, + }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + }, + findStream() { throw new Error('not implemented'); }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return next; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + for (const r of rows) storeFor(object).set(r.id as string, { ...r, ...data, id: r.id }); + return rows.length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** Capture a rejection so its structured `fields[]` can be inspected. */ +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the write to be refused, but it succeeded'); +} + +describe('[#4441] a lookup id that resolves to nothing is refused', () => { + let engine: ObjectQL; + let stores: Map>>; + const userCtx = { userId: 'u1' }; + + beforeEach(async () => { + engine = new ObjectQL(); + const mem = makeMemoryDriver(); + stores = mem.stores; + engine.registerDriver(mem.driver, true); + await engine.init(); + engine.registry.registerObject(permissionSet as any); + engine.registry.registerObject(binding as any); + engine.registry.registerObject(task as any); + await engine.insert('ref_permission_set', { id: 'ps_real', name: 'Real' }, { context: { isSystem: true } } as any); + }); + + it('the RBAC link table refuses a binding that points at nothing', async () => { + const err = await refusalOf(() => + engine.insert( + 'ref_position_permission_set', + { permission_set_id: 'ps_does_not_exist_at_all' }, + { context: userCtx } as any, + ), + ); + + // The envelope the issue asks for: 400-shaped `fields[]`, naming the field + // and the unresolvable id — the same shape a `required` violation carries. + expect(err.name).toBe('ValidationError'); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ + field: 'permission_set_id', + code: 'reference_not_found', + value: 'ps_does_not_exist_at_all', + }); + expect(err.fields[0].message).toContain('ps_does_not_exist_at_all'); + + // …and nothing was written. A security-surface row that resolves to + // nothing must not exist even briefly. + expect(stores.get('ref_position_permission_set')?.size ?? 0).toBe(0); + }); + + it('a resolvable binding is unaffected', async () => { + const row: any = await engine.insert( + 'ref_position_permission_set', + { permission_set_id: 'ps_real' }, + { context: userCtx } as any, + ); + expect(row.permission_set_id).toBe('ps_real'); + }); + + it('an ordinary application object is covered too', async () => { + const err = await refusalOf(() => + engine.insert('ref_task', { title: 'T', project: 'proj_does_not_exist' }, { context: userCtx } as any), + ); + expect(err.fields[0]).toMatchObject({ field: 'project', code: 'reference_not_found' }); + }); + + it('an UPDATE that repoints a lookup at nothing is refused', async () => { + const row: any = await engine.insert( + 'ref_task', { title: 'T', project: 'ps_real' }, { context: userCtx } as any, + ); + const err = await refusalOf(() => + engine.update('ref_task', { project: 'gone' }, { where: { id: row.id }, context: userCtx } as any), + ); + expect(err.fields[0]).toMatchObject({ field: 'project', code: 'reference_not_found' }); + // The stored value did not move. + const after: any = await engine.findOne('ref_task', { where: { id: row.id } } as any); + expect(after.project).toBe('ps_real'); + }); + + it('a BULK update is refused as well — the call site, not just the switch', async () => { + await engine.insert('ref_task', { title: 'A', project: 'ps_real' }, { context: userCtx } as any); + const err = await refusalOf(() => + engine.update( + 'ref_task', { project: 'gone' }, + { where: { title: 'A' }, multi: true, context: userCtx } as any, + ), + ); + expect(err.fields[0]).toMatchObject({ field: 'project', code: 'reference_not_found' }); + }); + + it('clearing a lookup is not a dangling reference', async () => { + const row: any = await engine.insert( + 'ref_task', { title: 'T', project: 'ps_real' }, { context: userCtx } as any, + ); + // `null` / '' mean "no link" — exactly what `deleteBehavior: 'set_null'` + // produces, so they must never be validated as ids. + await engine.update('ref_task', { project: null }, { where: { id: row.id }, context: userCtx } as any); + await engine.update('ref_task', { project: '' }, { where: { id: row.id }, context: userCtx } as any); + const after: any = await engine.findOne('ref_task', { where: { id: row.id } } as any); + expect(after.project === null || after.project === '').toBe(true); + }); + + it('every element of a multi-value lookup is checked', async () => { + const err = await refusalOf(() => + engine.insert( + 'ref_task', { title: 'T', tags: ['ps_real', 'ps_missing'] }, { context: userCtx } as any, + ), + ); + expect(err.fields[0]).toMatchObject({ field: 'tags', code: 'reference_not_found', value: 'ps_missing' }); + }); + + it('a system-context write is exempt, so seed replay keeps its ordering freedom', async () => { + // Seeds, package install and boot provisioning legitimately write rows in + // an order that only resolves once the batch completes. Failing them closed + // would turn an ordering detail into a boot failure. + const row: any = await engine.insert( + 'ref_position_permission_set', + { permission_set_id: 'ps_not_yet_seeded' }, + { context: { isSystem: true } } as any, + ); + expect(row.permission_set_id).toBe('ps_not_yet_seeded'); + }); + + it('a server-stamped lookup is never reported as the caller\'s bad reference', async () => { + // `owner_id` / `organization_id` / `created_by` are lookups too, written by + // hooks and middleware rather than by the request. The check reads only + // caller-supplied keys, so a stamp pointing at an unseeded row cannot turn + // into a caller-facing rejection. + const row: any = await engine.insert( + 'ref_task', { title: 'T' }, { context: { ...userCtx, userId: 'user_not_in_this_db' } } as any, + ); + expect(row.title).toBe('T'); + }); + + it('a value the PLATFORM derived is never reported as the caller\'s bad reference', async () => { + // A form serializes an unpicked control as an explicit `null`, and + // `applyFieldDefaults` then fills it from `defaultValue` — including the + // `current_user` token (#2706). The key IS in the payload, but the id that + // lands is the platform's, so checking it would reject an ordinary insert + // whenever the acting principal has no row in the target (exactly what a + // bare-engine / test driver looks like). Whether to check is decided by the + // caller's own raw value, not by key presence. + engine.registry.registerObject({ + name: 'ref_defaulted', + label: 'Defaulted', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + owner: { + name: 'owner', label: 'Owner', type: 'lookup' as const, + reference: 'ref_permission_set', defaultValue: 'ps_not_a_row', + }, + }, + } as any); + + const explicitNull: any = await engine.insert( + 'ref_defaulted', { title: 'T', owner: null }, { context: userCtx } as any, + ); + expect(explicitNull.owner).toBe('ps_not_a_row'); + + const omitted: any = await engine.insert( + 'ref_defaulted', { title: 'T2' }, { context: userCtx } as any, + ); + expect(omitted.owner).toBe('ps_not_a_row'); + + // …but a value the caller DID name is still checked. + const err = await refusalOf(() => + engine.insert('ref_defaulted', { title: 'T3', owner: 'nope' }, { context: userCtx } as any), + ); + expect(err.fields[0]).toMatchObject({ field: 'owner', code: 'reference_not_found', value: 'nope' }); + }); + + it('a READONLY lookup is not the caller\'s to answer for', async () => { + // By construction, not by exemption: `stripReadonlyFields` / + // `stripReadonlyForInsert` remove a non-system caller's value from a + // readonly field before the write, so anything still there was written by + // the PLATFORM — outside this check's stated scope. + // + // The real case that found this: `sys_metadata_history.recorded_by` is a + // `lookup('sys_user', { readonly: true })` the metadata repository fills + // with `actor ?? 'system'` — a SENTINEL STRING, not a user id — on a write + // that carries no `isSystem`. Checking it rejected ordinary metadata + // authoring (package create / publish / clone) in the dogfood gate. + engine.registry.registerObject({ + name: 'ref_history', + label: 'History', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + note: { name: 'note', label: 'Note', type: 'text' as const }, + recorded_by: { + name: 'recorded_by', label: 'Recorded By', + type: 'lookup' as const, reference: 'ref_permission_set', readonly: true, + }, + }, + } as any); + + const row: any = await engine.insert( + 'ref_history', { note: 'n', recorded_by: 'system' }, { context: userCtx } as any, + ); + expect(row.recorded_by).toBe('system'); + }); + + it('…and the issue\'s own fields are NOT readonly, so they stay enforced', () => { + // The narrowing above must not quietly cover the two fields #4441 names. + for (const [obj, field] of [ + ['ref_position_permission_set', 'permission_set_id'], + ['ref_task', 'project'], + ] as const) { + const def: any = (engine.registry.getObject(obj) as any).fields[field]; + expect(def.readonly, `${obj}.${field} must not be readonly`).not.toBe(true); + } + }); + + it('an unresolvable TARGET object fails open rather than blocking every write', async () => { + // A reference to an object that is not registered (another datasource, a + // package not installed) cannot be checked. An integrity check that cannot + // run must not invent a rejection. + engine.registry.registerObject({ + name: 'ref_orphan', + label: 'Orphan', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + other: { name: 'other', label: 'Other', type: 'lookup' as const, reference: 'not_registered_anywhere' }, + }, + } as any); + const row: any = await engine.insert( + 'ref_orphan', { other: 'whatever' }, { context: userCtx } as any, + ); + expect(row.other).toBe('whatever'); + }); +}); diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 4257103691..c8960f56e9 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -668,7 +668,9 @@ describe('ObjectQL Engine', () => { it('findOne accepts context via the trailing options arg', async () => { (mockDriver.findOne as any).mockResolvedValue({ id: '1' }); - await engine.findOne('task', {}, { context: { tenantId: 't-fo' } as any }); + // `where` is not incidental: findOne refuses a query that selects no + // particular record (#4419). + await engine.findOne('task', { where: { id: '1' } }, { context: { tenantId: 't-fo' } as any }); expect((mockDriver.findOne as any).mock.calls.at(-1)?.[2]).toMatchObject({ tenantId: 't-fo' }); }); @@ -2053,7 +2055,10 @@ describe('ObjectQL Engine', () => { { id: 'u1', name: 'Alice' }, ]); - const result = await engine.findOne('task', { expand: { assignee: { object: 'assignee' } } }); + const result = await engine.findOne('task', { + where: { id: 't1' }, + expand: { assignee: { object: 'assignee' } }, + }); expect(result.assignee).toEqual({ id: 'u1', name: 'Alice' }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 730ea3596e..ceb64f6041 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -17,7 +17,7 @@ import { type DroppedFieldsEvent } from '@objectstack/spec/data'; import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, @@ -76,7 +76,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; -import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField } from './validation/record-validator.js'; +import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField } from './validation/record-validator.js'; import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, stripReadonlyFields } from './validation/rule-validator.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { applyHaving } from './having-filter.js'; @@ -585,6 +585,19 @@ interface SummaryDescriptor { // on every build, so the seven consumer-local surface declarations the contract // replaced can never silently drift from the engine again. IObjectQLEngine // extends IDataEngine, so the old claim rides along. +/** + * [#4441] "The caller did not name a record here." + * + * `null` / `undefined` / `''` mean NO LINK — exactly what + * `deleteBehavior: 'set_null'` writes — and an empty array is the multi-value + * spelling of the same thing. None of them is an id to resolve. + */ +function isEmptyReferenceValue(v: unknown): boolean { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0 || v.every((e) => e === null || e === undefined || e === ''); + return false; +} + export class ObjectQL implements IObjectQLEngine { /** * Ambient transaction store (ADR-0034). While a `transaction()` callback @@ -1946,6 +1959,153 @@ export class ObjectQL implements IObjectQLEngine { }; } + /** + * [#4441] Referential integrity on the WRITE path: a `lookup` (or any + * reference-typed field) may not be given an id that exists in no row of the + * object it declares. + * + * The field metadata is unambiguous — `{"type":"lookup","required":true, + * "reference":"sys_permission_set"}` — and the DELETE side already reasons + * about the edge (`deleteBehavior: 'set_null'`). Only the insert side never + * checked, so `POST /data/sys_position_permission_set + * {"permission_set_id":"ps_does_not_exist_at_all"}` created the row. + * + * On the RBAC link tables that is a security-surface record that resolves to + * nothing: an administrator auditing permissions sees a binding whose target + * cannot be inspected, and the audience-anchor gate has to resolve that very + * set to evaluate the grant — so a dangling row is an unevaluable gate input, + * not merely an untidy one. + * + * ## Scope, deliberately narrow + * + * - **Caller-supplied keys only.** Server stamps (`owner_id`, + * `organization_id`, `created_by`/`updated_by`) are lookups too; they are + * written by hooks and middleware, not by the request, and re-validating + * them here would turn a platform stamp into a caller-facing rejection. + * - **Non-system writes only**, like every other write-path guard in this + * engine (`stripReadonlyFields`, `stripReadonlyForInsert`). Seed replay, + * package install and boot-time provisioning legitimately write rows in an + * order that resolves only once the batch completes; failing them closed + * would turn an ordering detail into a boot failure. This leaves a real + * residual — an `isSystem` caller can still write a dangling reference — + * which is recorded on the issue rather than silently accepted. + * - **Empty values are not references.** `null` / `undefined` / `''` mean + * "no link", which is what `deleteBehavior: 'set_null'` produces. + * - **Already-expanded objects are skipped.** A read round-trip can hand back + * `{id, name, …}` in the slot; that is not an id write. + * + * ## Why the probe is unscoped + * + * Existence is a fact about the database, not about the caller's visibility — + * the same distinction the #4435 existence probe turns on. A scoped probe + * would refuse a link to a permission set the caller cannot READ, which is + * ordinary in an RLS-scoped deployment and would make the platform's own + * admin flows fail. Whether the caller may create the binding at all is the + * RBAC/RLS layer's decision, made where it already is. + * + * Fails OPEN when the target cannot be checked (unregistered object, no + * driver, a probe that throws): an integrity check that cannot run must not + * invent a rejection, and the alternative — refusing every write to an object + * whose target lives on an unreachable datasource — converts a connectivity + * problem into data loss. + */ + private async assertReferencesResolve( + schema: any, + data: Record | null | undefined, + supplied: Record | null | undefined, + context: any, + msgCtx?: { locale?: string; translate?: any; objectName?: string }, + ): Promise { + if (context?.isSystem) return; + const fields = schema?.fields; + if (!fields || !data) return; + + const failures: any[] = []; + for (const name of Object.keys(fields)) { + // A `readonly` field is never the caller's to answer for — BY + // CONSTRUCTION, not by exemption. + // + // `stripReadonlyFields` removes a non-system caller's value from a + // readonly field before the write, and the create ingress does the same + // (`stripReadonlyForInsert`, #3043). So any value still sitting in one at + // this point was written by the PLATFORM, which puts it outside this + // check's own stated scope ("the reference the caller named"). + // + // Found by the dogfood gate rather than by reasoning: `sys_metadata_history. + // recorded_by` is `Field.lookup('sys_user', { readonly: true })` that the + // metadata repository fills with `actor ?? 'system'` — a SENTINEL STRING, + // not a user id, on a write that does not carry `isSystem`. Checking it + // rejected ordinary metadata authoring (package create / publish / clone). + // The sentinel-in-a-lookup is a real modelling wart and is filed + // separately; it is not this change's to fix, and rejecting the + // platform's own write is not the way to report it. + // + // This does NOT weaken #4441: the fields the issue names — + // `sys_position_permission_set.permission_set_id` and + // `showcase_task.project` — are ordinary author-facing lookups with no + // `readonly`, and both stay enforced (pinned in the unit suite). + if (fields[name]?.readonly === true) continue; + // Only a value the CALLER actually supplied is theirs to answer for. + // + // Key presence is not enough: a form serializes an unpicked control as + // an explicit `null`, and `applyFieldDefaults` then fills it from + // `defaultValue` — including the `current_user` token (#2706). The key is + // in the payload, but the ID that lands is the PLATFORM's, so validating + // it would report a server-derived value as the caller's bad reference + // (and reject a perfectly ordinary insert against a driver that has no + // `sys_user` row for the acting principal). + // + // So the value read for the check comes from the post-normalization + // `data` (multi-value strings are already split by then), while WHETHER + // to check is decided by the caller's own raw value being non-empty. + if (!supplied || isEmptyReferenceValue((supplied as Record)[name])) continue; + if (!(name in data)) continue; + const def = fields[name]; + const target = referenceTargetOf(def); + if (!target) continue; + const raw = (data as Record)[name]; + const values = Array.isArray(raw) ? raw : [raw]; + for (const v of values) { + if (v === null || v === undefined || v === '') continue; + if (typeof v === 'object') continue; + const resolved = await this.referenceExists(target, v); + if (resolved === false) { + failures.push(buildFieldError( + { + field: name, + code: 'reference_not_found', + def, + value: String(v), + constraint: { target }, + }, + msgCtx as any, + )); + } + } + } + if (failures.length > 0) throw new ValidationError(failures); + } + + /** + * Does `id` name a row in `target`? `false` only when the probe RAN and found + * nothing; `null` when it could not run at all (see the fail-open note on + * {@link assertReferencesResolve}). + */ + private async referenceExists(target: string, id: unknown): Promise { + try { + const resolved = this.resolveObjectName(target); + if (!this._registry.getObject(resolved)) return null; + const row = await this.findOne(resolved, { + where: { id }, + fields: ['id'], + context: { isSystem: true }, + } as any); + return !!row; + } catch { + return null; + } + } + /** * Register the crypto provider that backs `secret`-typed fields. * @@ -2205,13 +2365,45 @@ export class ObjectQL implements IObjectQLEngine { } // 2. Check datasourceMapping rules + // + // A rule that MATCHES is a routing decision, not a hint (#4462). It used to + // fall through to steps 3-5 whenever the named datasource had no live + // driver, which put an object's rows in the DEFAULT store while every + // signal said otherwise: boot succeeded, `/ready` answered 200, the + // datasource name appeared nowhere in the log, and the write returned 201. + // An operator who routes an object to Postgres and gets the URL wrong finds + // out by going to look in Postgres and finding it empty. + // + // `default` is the one name that legitimately resolves onward: the default + // driver keeps its NATURAL name (#3826), so `drivers.has('default')` is + // false by construction and step 5 is how routing to it works. const mappedDatasource = this.resolveDatasourceFromMapping(objectName, object); - if (mappedDatasource && this.drivers.has(mappedDatasource)) { - this.logger.debug('Resolved datasource from mapping', { - object: objectName, - datasource: mappedDatasource - }); - return this.drivers.get(mappedDatasource)!; + if (mappedDatasource && mappedDatasource !== 'default') { + if (this.drivers.has(mappedDatasource)) { + this.logger.debug('Resolved datasource from mapping', { + object: objectName, + datasource: mappedDatasource + }); + return this.drivers.get(mappedDatasource)!; + } + // Same three-way diagnosis as an explicit `object.datasource` binding — + // the two are the same promise made in two places, so they owe the reader + // the same answer. + const unavailable = this.unavailableDatasources.get(mappedDatasource); + if (unavailable) { + throw new DatasourceUnavailableError( + mappedDatasource, + objectName, + unavailable.kind, + unavailable.publicDetail, + ); + } + throw new Error( + `[ObjectQL] Datasource '${mappedDatasource}' mapped for object '${objectName}' is not registered. ` + + `A datasourceMapping rule routes this object to it, so falling back to the default store would ` + + `write the object's data to a different database than the one it declares. Fix the datasource ` + + `configuration, or remove the mapping rule.`, + ); } // 3. Lifecycle-class separation (ADR-0057 §3.6): high-frequency @@ -2255,6 +2447,26 @@ export class ObjectQL implements IObjectQLEngine { throw new Error(`[ObjectQL] No driver available for object '${objectName}'`); } + /** + * Which datasource do the mapping rules route `objectName` to, if any? + * + * The PUBLIC face of {@link resolveDatasourceFromMapping}, added for the boot + * path (#4462): the datasource-connection service must connect the + * datasources a mapping actually routes objects to, and it must learn which + * those are from the same resolver the query path uses. A second + * implementation of "does this rule match?" living in the connection service + * would drift by one clause and produce the worst of both postures — a + * datasource connected that routing does not use, or routed to and never + * connected, which is the defect itself. + * + * Returns `null` when no rule matches, and the datasource name (including + * `'default'`) when one does. Rule matching only — an explicit + * `object.datasource` binding outranks this and is not consulted here. + */ + resolveMappedDatasource(objectName: string): string | null { + return this.resolveDatasourceFromMapping(objectName, this._registry.getObject(objectName)); + } + /** * Resolve datasource from mapping rules * @@ -2965,10 +3177,18 @@ export class ObjectQL implements IObjectQLEngine { // declared it expandable. Reading the shared set is what stops the // protocol's expand gate (which validates against the same set) from ever // admitting a field this loop then silently skips. - if (!fieldDef || !fieldDef.reference) continue; + // + // [cloud#983] The TARGET comes from `referenceTargetOf` for that same + // anti-drift reason. A raw `fieldDef.reference` read made `{ type: + // 'user' }` (no `reference`) targetless here AND at the gate — but + // `user`'s target is fixed BY THE TYPE (`sys_user`; `Field.user()` takes + // no target argument), so the field was fully specified and the request + // was refused `400 … declares no target object`. Both sides now ask the + // one function what a reference field points at. + if (!fieldDef) continue; if (!REFERENCE_VALUE_TYPES.has(fieldDef.type)) continue; - - const referenceObject = fieldDef.reference; + const referenceObject = referenceTargetOf(fieldDef); + if (!referenceObject) continue; // Collect all foreign key IDs from records (handle both single and multiple values) const allIds: any[] = []; @@ -3236,6 +3456,97 @@ export class ObjectQL implements IObjectQLEngine { return resolved === options.where ? options : ({ ...options, where: resolved } as T); } + /** + * ADR-0061: expand `search` into a server-resolved cross-field `$or` of + * `$contains`, AND it with any caller `where`, then strip the search keys off + * the AST. + * + * Shared by `find` and `findOne` (#4419). It lived inline in `find` and + * nowhere else, while `ENGINE_FIND_OPTION_KEYS` — the one legal-key set BOTH + * methods are checked against (see {@link ENGINE_OPTION_KEY_SETS}) — declares + * `search`/`searchFields` for both. So `findOne({ search })` passed the gate, + * rode onto the AST verbatim, and reached a driver: no driver reads + * `ast.search` (the expansion is the engine's job by ADR-0061), so the + * predicate vanished and the forced `limit: 1` turned it into the first row of + * the WHOLE object — a real, plausible-looking record unrelated to the search. + * That is #4419's reported failure exactly, under a different key than the + * `filter` #4346 closed; one expander, called from both, is what stops the + * pair drifting again. + * + * Field resolution is server-side (declared `searchableFields` → + * auto-default); the optional `searchFields` override is intersected with the + * allowed set, never widened. All drivers already execute `$or`/`$contains`, + * so this needs no driver changes. + * + * The keys are deleted whether or not anything expanded — leaving them on + * would hand the driver a key it does not read, which is the same silent drop + * one layer down. + */ + private expandSearchOnAst(ast: QueryAST, schema: ServiceObject | undefined): void { + // The `$search`/`$searchFields` OData spellings are NOT read here: the + // protocol layer normalizes them to the bare keys before the engine + // (protocol.ts findData), and a direct engine call carrying one is an + // unknown option — rejected at the entry point, not silently dropped + // (#4371). + const raw = (ast as any).search; + if (raw != null && schema?.fields) { + const requestedFields = (ast as any).searchFields + ?? (typeof raw === 'object' ? raw?.fields : undefined); + const searchFilter = expandSearchToFilter(raw, { + fields: schema.fields as any, + searchableFields: (schema as any).searchableFields, + requestedFields, + // [ADR-0079] `nameField` is the canonical primary-title pointer; + // `displayNameField` is the deprecated alias (still honored). + displayField: (schema as any).nameField ?? (schema as any).displayNameField, + }); + if (searchFilter) { + ast.where = ast.where ? { $and: [ast.where, searchFilter] } : searchFilter; + } + } + delete (ast as any).search; + delete (ast as any).searchFields; + } + + /** + * Refuse a `findOne` that selects nothing in particular (#4419). + * + * The AST reaching here is the CALLER's own intent: aliases folded, unknown + * keys refused, `search` expanded — but the security/sharing middlewares have + * not run yet, and that ordering is the point. An injected RLS predicate + * narrows *which* rows are visible; it does not make "whichever of them comes + * first" a thing the caller asked for. Judging the post-middleware AST would + * pass every query on a scoped object and leave the hole open where it is + * most expensive. + * + * "Selects nothing" is read the same way #3896 read an empty sharing + * criteria: absent, `null`, or `{}` — the three shapes that mean "match every + * row". A `where` that is not a plain object (an expression tree) is the + * driver's to interpret, and counts as a predicate; this guard closes the one + * case that is unambiguously match-everything, not everything it cannot + * prove. + * + * `orderBy` is the other way to be specific, and a legitimate one — "the + * newest", "the highest priority". It is honored on this path by every + * driver, so it is a real answer and not a second silent drop. + */ + private requireFindOnePredicate(object: string, ast: QueryAST): void { + const where = ast.where as unknown; + const hasPredicate = + where != null && + (typeof where !== 'object' || Array.isArray(where) || Object.keys(where).length > 0); + if (hasPredicate) return; + if (Array.isArray(ast.orderBy) && ast.orderBy.length > 0) return; + throw new Error( + `findOne('${object}') selects no particular record: 'where' is absent or empty ` + + `and the query carries no 'orderBy'. findOne applies limit: 1, so this would return an ` + + `ARBITRARY row — a real, plausible-looking record unrelated to what was asked for, which ` + + `no caller's null-check can catch (#4419). Pass 'where' (or a 'search' that resolves to ` + + `one) to select the record; pass 'orderBy' if you mean "the first record in THIS order"; ` + + `or call find('${object}', { limit: 1 }) if any row will genuinely do.`, + ); + } + async find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { object = this.resolveObjectName(object); // Normalize the alias spellings (`filter`→`where`, `top`→`limit`) by the @@ -3263,35 +3574,7 @@ export class ObjectQL implements IObjectQLEngine { // fields needed to compute the formulas after fetch. const _findSchema = this._registry.getObject(object); - // ADR-0061: expand `$search` into a server-resolved cross-field `$or` - // of `$contains`. Field resolution is server-side (declared - // `searchableFields` -> auto-default); the optional `$searchFields` override - // is intersected with the allowed set. All drivers already execute - // `$or`/`$contains`, so this needs no driver changes. - { - // The `$search`/`$searchFields` OData spellings are NOT read here: the - // protocol layer normalizes them to the bare keys before the engine - // (protocol.ts findData), and a direct engine call carrying one is an - // unknown option — rejected above, not silently dropped (#4371). - const _searchRaw = (ast as any).search; - if (_searchRaw != null && _findSchema?.fields) { - const _reqFields = (ast as any).searchFields - ?? (typeof _searchRaw === 'object' ? _searchRaw?.fields : undefined); - const _searchFilter = expandSearchToFilter(_searchRaw, { - fields: _findSchema.fields as any, - searchableFields: (_findSchema as any).searchableFields, - requestedFields: _reqFields, - // [ADR-0079] `nameField` is the canonical primary-title pointer; - // `displayNameField` is the deprecated alias (still honored). - displayField: (_findSchema as any).nameField ?? (_findSchema as any).displayNameField, - }); - if (_searchFilter) { - ast.where = ast.where ? { $and: [ast.where, _searchFilter] } : _searchFilter; - } - } - delete (ast as any).search; - delete (ast as any).searchFields; - } + this.expandSearchOnAst(ast, _findSchema); const _findFormula = planFormulaProjection(_findSchema, ast.fields); if (_findFormula.projected) ast.fields = _findFormula.projected; @@ -3383,6 +3666,33 @@ export class ObjectQL implements IObjectQLEngine { return opCtx.result as any[]; } + /** + * Read the ONE record the query selects, or `null`. + * + * `findOne` applies `limit: 1` by contract — so unlike `find`, the query's + * predicate is the only thing standing between the caller and *an arbitrary + * row*. A query that selects nothing in particular does not return nothing; + * it returns the object's first row, which is a real, plausible-looking + * record that no caller's `if (!row)` check can catch, and that propagates + * into whatever is computed next (#4419). So this method REQUIRES the caller + * to say which record it wants: + * + * - `where` (or the `filter` alias, folded here), or a `search` that expands + * to one — the record is selected by predicate. + * - `orderBy` — "the FIRST record in this order" (the newest, the highest + * priority). Deterministic without a predicate, and honored by every + * driver on this path. + * + * Neither → throws. If any row genuinely will do, that is + * `find(object, { limit: 1 })`, which says so at the call site. + * + * No ordering is IMPOSED when the caller supplies none: `ORDER BY LIMIT + * 1` makes a planner abandon the predicate's own index (objectstack#4363, and + * see `SqlDriver.findRows`' `singleRowLookup`). `findOne` promises *a* + * matching record, never a position in a sequence. + * + * Fires the same `beforeFind`/`afterFind` hooks as `find` (#3195). + */ async findOne(objectName: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { objectName = this.resolveObjectName(objectName); // Same alias fold as find() (#4346). Without it, `findOne({ filter })` @@ -3404,6 +3714,10 @@ export class ObjectQL implements IObjectQLEngine { // Plan formula projection (same as find): rewrite ast.fields so the driver // returns the raw dependency fields, then evaluate formulas after fetch. const _findOneSchema = this._registry.getObject(objectName); + // Before the guard below, so a `search` that resolves to a real filter + // counts as the predicate it is (#4419). + this.expandSearchOnAst(ast, _findOneSchema); + this.requireFindOnePredicate(objectName, ast); const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields); if (_findOneFormula.projected) ast.fields = _findOneFormula.projected; @@ -3593,12 +3907,25 @@ export class ObjectQL implements IObjectQLEngine { // Locale + translation hooks for the rejection messages (#3957) — // resolved once for the batch, identical for every row. const msgCtx = this.validationMessageContext(object, opCtx.context); + // [#4441] The RAW caller payload per row — before `applyFieldDefaults` + // resolved any `defaultValue` / `current_user` token and before the + // beforeInsert hooks stamped `owner_id` / `organization_id` / + // `created_by`. The reference check consults it to decide WHAT THE + // CALLER ACTUALLY SENT, so neither a platform stamp nor a backfilled + // default is ever reported as the caller's bad reference. + const suppliedPerRow: Array> = + (isBatch ? (opCtx.data as any[]) : [opCtx.data]).map( + (row) => (row ?? {}) as Record, + ); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; try { normalizeMultiValueFields(schemaForValidation, rows[i]); validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx }); evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx }); + await this.assertReferencesResolve( + schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, + ); } catch (e) { if (!partialMode) throw e; rowErrors[i] = e; @@ -3913,6 +4240,11 @@ export class ObjectQL implements IObjectQLEngine { reportDroppedFields(preRo, hookContext.input.data as Record, 'readonly'); } evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); + // [#4441] A repoint is as capable of dangling as an initial link. + await this.assertReferencesResolve( + updateSchema, hookContext.input.data as Record, + opCtx.data as Record, opCtx.context, updateMsgCtx, + ); result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } else if (options?.multi && driver.updateMany) { await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options); @@ -3991,6 +4323,13 @@ export class ObjectQL implements IObjectQLEngine { } else { evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); } + // [#4441] The bulk call site too — a guard wired into single-id + // writes only is still a hole one call site over (AGENTS.md + // PD #10's own worked example, #3106). + await this.assertReferencesResolve( + updateSchema, hookContext.input.data as Record, + opCtx.data as Record, opCtx.context, updateMsgCtx, + ); result = await driver.updateMany(object, ast, hookContext.input.data as Record, hookContext.input.options as any); } else { throw new Error('Update requires an ID or options.multi=true'); diff --git a/packages/objectql/src/protocol-data.test.ts b/packages/objectql/src/protocol-data.test.ts index 8ba3ce6aaf..bf7a26cd9f 100644 --- a/packages/objectql/src/protocol-data.test.ts +++ b/packages/objectql/src/protocol-data.test.ts @@ -403,16 +403,26 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { // ═══════════════════════════════════════════════════════════════ describe('Optimistic Concurrency Control', () => { beforeEach(() => { - // Both update and delete need `update` / `delete` on the - // engine, plus `findOne` for the version probe. + // Both update and delete need `update` / `delete` on the engine, + // plus `findOne` for the record probe. + // + // [#4435] The record now has to EXIST for a PATCH to proceed at + // all: `updateData` refuses an id that names no row instead of + // answering `200 { record: null }`. The default probe result is + // therefore a real row — these cases are about OCC, not about + // missing records (which `updateData refuses an id …` covers). + mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' }); mockEngine.update = vi.fn().mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:33.000Z' }); mockEngine.delete = vi.fn().mockResolvedValue(true); }); it('updateData proceeds when no expectedVersion is supplied (legacy callers)', async () => { await protocol.updateData({ object: 'task', id: 'r1', data: { name: 'New' } }); - // No version probe was issued - expect(mockEngine.findOne).not.toHaveBeenCalled(); + // [#4435] One probe — the EXISTENCE probe, which every PATCH now + // makes. No OCC comparison happens (no token was supplied), which + // is what "legacy callers are unaffected" means: they are not + // subject to 409, only to the 404 that GET already answered. + expect(mockEngine.findOne).toHaveBeenCalledOnce(); expect(mockEngine.update).toHaveBeenCalledOnce(); }); @@ -424,10 +434,31 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { data: { name: 'New' }, expectedVersion: '2026-05-22T07:14:00.000Z', }); + // [#4435] Still exactly ONE read. The existence gate and OCC both + // need this row, so they share the probe rather than issuing one + // each — two round-trips per PATCH would be a performance + // regression no gate reports, and the second read could disagree + // with the first. expect(mockEngine.findOne).toHaveBeenCalledOnce(); expect(mockEngine.update).toHaveBeenCalledOnce(); }); + it('updateData refuses an id that names no row, before any OCC verdict', async () => { + // [#4435] 404 wins over 409 when both could apply: OCC has always + // declined to treat a missing record as a concurrency conflict, and + // "this record does not exist" is the more specific answer. + mockEngine.findOne.mockResolvedValue(null); + await expect( + protocol.updateData({ + object: 'task', + id: 'gone', + data: { name: 'New' }, + expectedVersion: '2026-05-22T07:14:00.000Z', + }) + ).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect(mockEngine.update).not.toHaveBeenCalled(); + }); + it('updateData strips RFC-7232 quotes from the If-Match token', async () => { mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' }); await protocol.updateData({ @@ -475,16 +506,29 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { }); it('updateData skips the check when expectedVersion is empty string', async () => { + // A blank token opts OUT of OCC — the record still has to exist + // (#4435), so the probe happens; what must NOT happen is a 409. await protocol.updateData({ object: 'task', id: 'r1', data: { name: 'New' }, expectedVersion: ' ', }); - expect(mockEngine.findOne).not.toHaveBeenCalled(); + expect(mockEngine.findOne).toHaveBeenCalledOnce(); expect(mockEngine.update).toHaveBeenCalledOnce(); }); + it('deleteData without a token issues NO probe at all', async () => { + // [#4435] DELETE needs no existence probe: the driver's own return + // ("True if deleted, false if not found") reports whether a row + // matched, so a plain DELETE stays at zero extra reads and only an + // OCC token buys one. + mockEngine.findOne.mockClear(); + await protocol.deleteData({ object: 'task', id: 'r1' }); + expect(mockEngine.findOne).not.toHaveBeenCalled(); + expect(mockEngine.delete).toHaveBeenCalledOnce(); + }); + it('deleteData throws ConcurrentUpdateError on version mismatch', async () => { mockEngine.findOne.mockResolvedValue({ id: 'r1', diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 1b98d0835b..0ade020953 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -23,9 +23,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.auth.enabled).toBe(false); expect(discovery.services.auth.status).toBe('unavailable'); expect(discovery.services.auth.message).toContain('plugin-auth'); - // capabilities removed — derive from services - expect(discovery.services.workflow).toBeDefined(); - expect(discovery.services.workflow.enabled).toBe(false); + // capabilities removed — derive from services. (`workflow` was the slot + // pinned here until it retired in #4451; it must now be absent entirely.) + expect(discovery.services).not.toHaveProperty('workflow'); }); it('should return available auth service when auth is registered', async () => { @@ -109,18 +109,20 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => }); it('should report a __serviceInfo-marked service with its declared status', async () => { + // Was pinned on `workflow` until that slot retired (#4451, v17); `auth` + // exercises the same marked-service path. const mockServices = new Map(); - mockServices.set('workflow', { + mockServices.set('auth', { __serviceInfo: { status: 'degraded', message: 'partial impl' }, }); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.services.workflow.enabled).toBe(true); - expect(discovery.services.workflow.status).toBe('degraded'); - expect(discovery.services.workflow.handlerReady).toBe(true); - expect(discovery.services.workflow.message).toBe('partial impl'); + expect(discovery.services.auth.enabled).toBe(true); + expect(discovery.services.auth.status).toBe('degraded'); + expect(discovery.services.auth.handlerReady).toBe(true); + expect(discovery.services.auth.message).toBe('partial impl'); }); it('should report a registered analytics service with its self-declared status', async () => { @@ -332,11 +334,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.routes.i18n).toBe('/api/v1/i18n'); }); - // Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer), - // `workflow`, `graphql` and the queue/job/cache families have their routes - // mounted by the plugin that registers the service, so `handlerReady` there - // says nothing about whether THAT route is mounted and the advertisement - // stays presence-gated. Suppressing it would be a guess, not honesty. + // Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST + // layer) has its route mounted by the plugin that registers the service, + // so `handlerReady` there says nothing about whether THAT route is mounted + // and the advertisement stays presence-gated. Suppressing it would be a + // guess, not honesty. (cache/queue/job used to be named here on the same + // theory, but nothing mounts routes for them at all — route-less + // kernel-internal slots since #4318; `workflow` and `graphql` retired + // outright in #4451.) it('should leave non-dispatcher-owned routes presence-gated', async () => { const mockServices = new Map(); mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } }); @@ -348,6 +353,49 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.search.route).toBe('/api/v1/search'); }); + // ── Kernel-internal slots advertise no route, ever (#4318) ──────────────── + // SERVICE_CONFIG used to declare /api/v1/cache, /api/v1/queue and + // /api/v1/jobs — three paths that existed nowhere else in the repository: + // no dispatcher domain, no adapter mount, no plugin registration, and the + // shipped providers (service-cache/-queue/-job) are in-process contracts + // that will never mount one. Every default boot therefore advertised a + // route inside the same ServiceInfo whose `handlerReady: false` said the + // opposite. The slots are route-less now, like realtime — but unlike + // realtime an unmarked real implementation stays `available`, because the + // slot's contract is in-process and "no HTTP surface" is not reduced + // capability for it. + it('reports an unmarked cache/queue/job occupant available with no route and handlerReady false (#4318)', async () => { + const mockServices = new Map(); + for (const slot of ['cache', 'queue', 'job']) mockServices.set(slot, { /* real, unmarked */ }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + for (const slot of ['cache', 'queue', 'job']) { + const reported = discovery.services[slot]; + expect(reported.enabled, `${slot}.enabled`).toBe(true); + expect(reported.status, `${slot}.status`).toBe('available'); + expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false); + expect(reported.route, `${slot}.route`).toBeUndefined(); + expect(reported.message, `${slot}.message`).toContain('no HTTP surface'); + } + }); + + it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => { + for (const slot of ['cache', 'queue', 'job']) { + const mockServices = new Map(); + mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]()); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const reported = (await protocol.getDiscovery()).services[slot]; + + // Self-description wins for status/message (the class-wide #3898 gate + // pins `degraded`); the route stays gone and handlerReady stays false. + expect(reported.route, `${slot}.route`).toBeUndefined(); + expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false); + } + }); + it('should map file-storage service to storage route', async () => { const mockServices = new Map(); mockServices.set('file-storage', {}); @@ -388,17 +436,19 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => }); it('should return capabilities field populated from registered services', async () => { + // Was pinned on `workflow` until that slot retired (#4451, v17); `ui` + // is likewise registered without mapping to a well-known capability. const mockServices = new Map(); - mockServices.set('workflow', {}); - + mockServices.set('ui', {}); + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - + // capabilities field should now exist in the response expect(discovery.capabilities).toBeDefined(); - // workflow is registered but doesn't map to a well-known capability directly - expect(discovery.services.workflow.enabled).toBe(true); - // All well-known capabilities should be disabled since workflow doesn't map to any + // ui is registered but doesn't map to a well-known capability directly + expect(discovery.services.ui.enabled).toBe(true); + // All well-known capabilities should be disabled since ui doesn't map to any // (comments derives from the sys_comment object, which is not registered here). expect(discovery.capabilities!.comments).toEqual({ enabled: false }); expect(discovery.capabilities!.automation).toEqual({ enabled: false }); diff --git a/packages/objectql/src/protocol-meta-type-canonicalization.test.ts b/packages/objectql/src/protocol-meta-type-canonicalization.test.ts new file mode 100644 index 0000000000..e22cc449aa --- /dev/null +++ b/packages/objectql/src/protocol-meta-type-canonicalization.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4432 — the `/meta` type segment folds to ONE canonical namespace. + * + * #3985 made the per-type GATES accept both spellings of the type segment. It + * did not fold them, so `PUT /meta/actions/x` and `PUT /meta/action/x` addressed + * two different overlay namespaces and the layers below disagreed about which + * one an item lived in. The worst of it was not the duplicate row — it was the + * shadowing: `getMetaItems` registered overlay rows back into the SchemaRegistry + * under the CALLER's spelling, so one plural-spelled read minted a plural + * registry entry, `listItems('actions')` stopped being empty, and the singular + * fallback that had been supplying every code-authored action never ran again. + * One overlay row hid an entire code-authored listing, on a spelling that no + * DELETE could reach. + * + * These tests are written against the shape of the defect, not its wording: + * each fails if the fold is removed from `getMetaItems` / `getMetaItem` / + * `saveMetaItem` / `deleteMetaItem`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; + +/** One env-wide, active overlay row for `rc1_probe`, stored under the canonical type. */ +const OVERLAY_ROW = { + id: 'row_1', + type: 'action', + name: 'rc1_probe', + state: 'active', + organization_id: null, + package_id: null, + version: 1, + metadata: JSON.stringify({ name: 'rc1_probe', label: 'Probe' }), +}; + +describe('#4432 — canonical `/meta` type segment', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + let rows: any[]; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + // Two CODE-AUTHORED actions, indexed the way the artifact loader indexes + // them: under the SINGULAR metadata type name (Prime Directive #3). + registry.registerItem('action', { name: 'code_one', label: 'One' }, 'name'); + registry.registerItem('action', { name: 'code_two', label: 'Two' }, 'name'); + + rows = [OVERLAY_ROW]; + engine = { + registry, + find: vi.fn(async (_table: string, opts: any) => { + const w = opts?.where ?? {}; + return rows.filter((r) => + (w.type === undefined || r.type === w.type) + && (w.name === undefined || r.name === w.name) + && (w.state === undefined || r.state === w.state) + && (w.organization_id === undefined || r.organization_id === w.organization_id)); + }), + findOne: vi.fn(async (table: string, opts: any) => { + const found = await engine.find(table, opts); + return found[0] ?? null; + }), + insert: vi.fn(async () => ({ id: 'new' })), + update: vi.fn(async () => ({ id: 'row_1' })), + delete: vi.fn(async (_t: string, opts: any) => { + const id = opts?.where?.id; + rows = rows.filter((r) => r.id !== id); + return { deleted: 1 }; + }), + count: vi.fn(async () => 0), + aggregate: vi.fn(async () => []), + }; + protocol = new ObjectStackProtocolImplementation(engine); + }); + + const namesOf = (res: any): string[] => + (Array.isArray(res) ? res : res?.items ?? []).map((i: any) => i?.name).sort(); + + it('a plural-spelled list is artifact ∪ overlay — never overlay-only', async () => { + // The step-3 symptom: `GET /meta/actions` returned ONLY the overlay and + // hid every code-authored action. + expect(namesOf(await protocol.getMetaItems({ type: 'actions' }))) + .toEqual(['code_one', 'code_two', 'rc1_probe']); + expect(namesOf(await protocol.getMetaItems({ type: 'action' }))) + .toEqual(['code_one', 'code_two', 'rc1_probe']); + }); + + it('reading the plural spelling does not mint a phantom namespace', async () => { + // The mechanism, pinned directly. The first read used to REGISTER the + // overlay under the plural key; from the second read on, the non-empty + // `listItems('actions')` suppressed the singular fallback and the + // code-authored actions were gone for the rest of the process — and the + // phantom outlived any DELETE, because nothing addresses that key. + await protocol.getMetaItems({ type: 'actions' }); + + expect(registry.listItems('actions')).toEqual([]); + expect(namesOf(await protocol.getMetaItems({ type: 'actions' }))) + .toEqual(['code_one', 'code_two', 'rc1_probe']); + // …and repeated reads stay stable rather than degrading once more. + await protocol.getMetaItems({ type: 'actions' }); + expect(namesOf(await protocol.getMetaItems({ type: 'action' }))) + .toEqual(['code_one', 'code_two', 'rc1_probe']); + }); + + it('both spellings resolve the same single item', async () => { + const plural: any = await protocol.getMetaItem({ type: 'actions', name: 'rc1_probe' }); + const singular: any = await protocol.getMetaItem({ type: 'action', name: 'rc1_probe' }); + expect(plural?.item?.name).toBe('rc1_probe'); + expect(singular?.item?.name).toBe('rc1_probe'); + // The response states the CANONICAL type, so a client cannot round-trip + // a non-canonical spelling back into a second namespace. + expect(plural?.type).toBe('action'); + expect(singular?.type).toBe('action'); + }); + + /** + * Every `type` any storage lookup was issued against during the calls so + * far. The write and delete paths run through `SysMetadataRepository`, + * whose full transaction/history machinery is out of scope for a mock — but + * the namespace question is answerable without it. + * + * Stated plainly: the two assertions below already held before this fix. + * `SysMetadataRepository` folded to singular on its own, so the ROW was + * always canonical; what read the caller's spelling was everything around + * it — the authorization tier, the registry heal, and (the damaging one) + * `getMetaItems`' registry hydration. These pin the property so a future + * change cannot reintroduce the split from the write side either. + */ + const queriedTypes = (): string[] => { + const types = new Set(); + for (const call of [...engine.find.mock.calls, ...engine.findOne.mock.calls]) { + const t = call?.[1]?.where?.type; + if (typeof t === 'string') types.add(t); + } + return [...types].sort(); + }; + + it('a plural-spelled write addresses the canonical namespace and no other', async () => { + await protocol.saveMetaItem({ + type: 'actions', + name: 'rc1_probe2', + item: { name: 'rc1_probe2', label: 'Probe 2', target: 'noop' }, + }).catch(() => { /* repository machinery is out of scope — the lookups are not */ }); + + const types = queriedTypes(); + expect(types.length).toBeGreaterThan(0); + expect(types).toContain('action'); + expect(types).not.toContain('actions'); + + const writtenTypes = [ + ...engine.insert.mock.calls.map((c: any[]) => c[1]?.type), + ...engine.update.mock.calls.map((c: any[]) => c[1]?.type), + ].filter((t) => typeof t === 'string'); + expect(writtenTypes).not.toContain('actions'); + }); + + it('a plural-spelled DELETE addresses the canonical namespace and no other', async () => { + // The step-4 symptom: the row a plural PUT created was unreachable by + // either spelling, because the authorization tier and the registry heal + // read the caller's spelling while the repository deleted the singular. + await protocol.deleteMetaItem({ type: 'actions', name: 'rc1_probe' }) + .catch(() => { /* as above */ }); + + const types = queriedTypes(); + expect(types.length).toBeGreaterThan(0); + expect(types).toContain('action'); + expect(types).not.toContain('actions'); + }); +}); diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index f6ffe516a7..e1d92520a3 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -202,6 +202,7 @@ function makeMemoryDriver() { describe('#4226 — sort / select / expand on the list path (real ObjectQL engine)', () => { let engine: ObjectQL; let protocol: ObjectStackProtocolImplementation; + let stores: Map>>; /** The issue's transcript order: five rows inserted `C A E B D`. */ const INSERTION_ORDER = ['C', 'A', 'E', 'B', 'D']; @@ -210,7 +211,9 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin beforeEach(async () => { engine = new ObjectQL(); - const { driver, stores } = makeMemoryDriver(); + const made = makeMemoryDriver(); + const driver = made.driver; + stores = made.stores; engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(projectObject as any, 'test-package'); @@ -599,6 +602,54 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin .rejects.toThrow(/declares no target object/); }); + it('a `user` field carries its target IN THE TYPE — bare `{type:"user"}` expands (cloud#983)', async () => { + // `field.zod` defines `user` as "a lookup specialized to the `sys_user` + // system object … target fixed to the `sys_user` system object", and + // `Field.user()` takes no target argument — it writes + // `reference: 'sys_user'` itself. So a field authored WITHOUT + // `reference` (hand-written JSON, an AI author, a Studio form) is fully + // specified, and the gate above must not read it as the previous test's + // targetless lookup. + // + // Live capture: an AI-built app modelled 负责人 as `{ type: 'user' }`, + // objectui's default list expanded that column (its + // `EXPANDABLE_FIELD_TYPES` keys on the TYPE, deliberately ignoring the + // target), and the very first screen of the new app rendered + // "该视图的查询被拒绝" over a `400 … declares no target object`. + engine.registry.registerObject({ + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' }, + }, + } as any, 'test-package'); + engine.registry.registerObject({ + name: 'showcase_equipment', + label: 'Equipment', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' }, + // No `reference` — exactly as captured. + responsible_person: { name: 'responsible_person', label: '负责人', type: 'user' }, + }, + } as any, 'test-package'); + stores.set('sys_user', new Map([['usr_1', { id: 'usr_1', name: 'Ada' }]])); + stores.set('showcase_equipment', new Map([ + ['e1', { id: 'e1', name: 'Lathe', responsible_person: 'usr_1' }], + ])); + + // Admitted — and, the half a gate-only fix would miss, actually + // EXPANDED. Letting the request through while the engine still skipped + // the field would answer 200 with a raw user id in the cell, which is + // the "client renders raw ids where names belong" failure this whole + // axis exists to close. + const r: any = await protocol.findData({ + object: 'showcase_equipment', query: { populate: 'responsible_person' }, + }); + expect(r.records[0].responsible_person).toMatchObject({ id: 'usr_1', name: 'Ada' }); + }); + // ───────────────────────────────────────────────────────────── // The single-record route answers identically (#4226) // ───────────────────────────────────────────────────────────── diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index fd50683d53..4d395b4ef5 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -280,6 +280,24 @@ const AUDIT_FIELD_DEFS = { }, } satisfies Record>; +/** + * [#4447] The subset of {@link AUDIT_FIELD_DEFS} that is NOT authorable — the + * keys that decide who may write an audit column. + * + * Only `readonly` / `system` travel: everything else an author writes — + * `label`, `description`, `hidden`, `group`, and even `type` for an + * external object mapping a differently-typed remote column — stays theirs. + */ +const AUDIT_FIELD_GOVERNANCE: Record> = + Object.fromEntries( + // ONLY the keys that decide WHO MAY WRITE the column. `type` and + // `reference` are deliberately NOT forced: an external/federated object + // legitimately maps its audit column to a differently-typed remote column, + // and #4447 is about writability, not storage shape. Narrower is the point + // — this overrides an author, so it takes only what the defect requires. + AUDIT_PROVENANCE_FIELDS.map((name) => [name, { readonly: true, system: true }]), + ) as unknown as Record>; + export function applySystemFields( schema: ServiceObject, opts: { multiTenant: boolean } @@ -344,6 +362,9 @@ export function applySystemFields( !schema.name.startsWith('sys_'); const additions: Record = {}; + // Platform-owned field settings that must WIN over a declared field, rather + // than lose to it like `additions` does (#4447). + const overrides: Record = {}; if (wantTenant && !schema.fields?.organization_id) { additions.organization_id = { @@ -362,7 +383,36 @@ export function applySystemFields( if (wantAudit) { for (const name of AUDIT_PROVENANCE_FIELDS) { - if (!schema.fields?.[name]) additions[name] = AUDIT_FIELD_DEFS[name]; + const declared = (schema.fields as Record | undefined)?.[name]; + if (!declared) { + additions[name] = AUDIT_FIELD_DEFS[name]; + continue; + } + // [#4447] The audit family's GOVERNANCE is platform-owned, so a declared + // `created_at` cannot make the audit anchor client-writable. + // + // The injection above is skipped when the object already carries the + // field, and the merge below lets `schema.fields` win — correct for an + // authored business field, wrong for this family. It is how `created_at` + // became writable on an ordinary PATCH: the showcase artifact ships a + // materialized `created_at` carrying only FieldSchema DEFAULTS + // (`readonly: false`), which shadowed `AUDIT_FIELD_DEFS.created_at` + // (`readonly: true`), so the engine's `stripReadonlyFields` had nothing + // to key off and the forged value was written straight through — with no + // `droppedFields` either, because from the platform's point of view + // nothing was dropped. + // + // Its two siblings only LOOKED protected: the audit hook force-advances + // `updated_at`/`updated_by` on every update, so a forged value is + // overwritten rather than refused. `created_at` is insert-only, so + // nothing overwrote it — one field out of the trio genuinely unguarded. + // + // Presentation stays the author's (label, description, hidden, group, + // ordering …); only the keys that decide WHO MAY WRITE IT are forced. + // That leaves the deliberate back-dating path intact: `preserveAudit` + // (#3479/#3493) and `isSystem` writes still reinstate the original + // timeline, because they are checked downstream of `readonly`, not by it. + overrides[name] = { ...declared, ...AUDIT_FIELD_GOVERNANCE[name] }; } } @@ -385,11 +435,13 @@ export function applySystemFields( }; } - if (Object.keys(additions).length === 0) return schema; + if (Object.keys(additions).length === 0 && Object.keys(overrides).length === 0) return schema; return { ...schema, - fields: { ...additions, ...(schema.fields ?? {}) }, + // `additions` LOSE to an author's field (a declared `owner_id` is theirs); + // `overrides` WIN over it (the audit family's governance is not authorable). + fields: { ...additions, ...(schema.fields ?? {}), ...overrides }, }; } diff --git a/packages/objectql/src/validation/scan-value-shapes.test.ts b/packages/objectql/src/validation/scan-value-shapes.test.ts index 59a69c7142..1451290074 100644 --- a/packages/objectql/src/validation/scan-value-shapes.test.ts +++ b/packages/objectql/src/validation/scan-value-shapes.test.ts @@ -103,6 +103,45 @@ describe('scanValueShapes (ADR-0104 D1 / #3438)', () => { expect(valueShapeScanPassed(report)).toBe(false); }); + it('#4455: a lookup holding a SERIALIZED embedded record is found, and closes the gate', async () => { + // The exact case the scan's own header names — and the exact way it reaches + // a SQL deployment: the expanded record object stored as JSON text in a + // TEXT column. It read as a non-empty string, so `ReferenceIdValueSchema` + // waved it through, the scan reported "✓ No malformed values found", and + // `--apply` closed the gate on evidence that was never collected. + const engine = makeEngine({ + contact: [ + { id: 'c1', account: 'acc_1' }, // a real id — must stay clean + { id: 'c2', account: '{"id":"acc_1","name":"embedded"}' }, + { id: 'c3', account: ' {"id":"acc_2"}' }, + ], + }); + const report = await scanValueShapes(engine, silent); + + expect(report.scannedRecords).toBe(3); + expect(report.blocking).toBe(2); + const account = report.findings.find((f) => f.field === 'account')!; + expect(account.count).toBe(2); + expect(account.sampleRecordIds).toEqual(['c2', 'c3']); + expect(account.detail).toMatch(/embedded record object/); + // The verdict the gate reads: this deployment may NOT record the flag. + expect(valueShapeScanPassed(report)).toBe(false); + + // …and the same value is a write rejection under strict, so the flag would + // not have been attesting something the validator disagrees with. + expect(() => + validateRecord( + OBJECTS.contact, + { account: '{"id":"acc_1","name":"embedded"}' }, + 'update', + { valueShapeStrict: true }, + ), + ).toThrow(ValidationError); + expect(() => + validateRecord(OBJECTS.contact, { account: 'acc_1' }, 'update', { valueShapeStrict: true }), + ).not.toThrow(); + }); + it('the scan counts exactly what strict mode rejects — one predicate, not two', async () => { // The anti-drift property: every value the scan flags must also be a write // rejection under the strict gate, and every value it passes must write. diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index 229f13ad86..10aa608901 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -3,6 +3,7 @@ import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data'; import { canonicalAstOperator } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; +import { StandardErrorCode } from '@objectstack/spec/api'; import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core'; import { Query, Aggregator } from 'mingo'; import { getValueByPath } from './memory-matcher.js'; @@ -12,6 +13,24 @@ import { type TemporalFieldKind, } from './memory-temporal.js'; +/** + * [#4436] A filter this driver cannot COMPILE — see the twin in + * `driver-sql`'s `unsupportedFilterError`, which carries the full rationale. + * + * Kept in lockstep with driver-sql deliberately: #3948 made the two backends + * AGREE that an uncompilable filter is a refusal rather than a silent + * match-everything, and the refusal's wire envelope has to agree too. A test + * suite that swaps the memory driver for SQL must see the same `400 + * INVALID_FILTER`, not a coded refusal on one backend and a bare `{error}` on + * the other. + */ +function unsupportedFilterError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + return err; +} + /** * Persistence adapter interface. * Matches the PersistenceAdapterSchema contract from @objectstack/spec. @@ -764,8 +783,8 @@ export class InMemoryDriver implements IDataDriver { // matches EVERY record. An unapplied filter must not look like a // satisfied one. #3948. if (lower !== 'and' && lower !== 'or') { - throw new Error( - `[driver-memory] Unrecognized filter operator "${item}" in a comparison triple. ` + + throw unsupportedFilterError( + `Unrecognized filter operator "${item}" in a comparison triple. ` + `A filter array is either a logical node (["and"|"or", …]) or nested ` + `conditions ([[field, op, value], …]); a bare [field, op, value] only ` + `reaches the driver when its operator is outside @objectstack/spec ` + @@ -785,8 +804,8 @@ export class InMemoryDriver implements IDataDriver { const cond = this.convertConditionToMongo(field, operator, value, object); if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond); } else { - throw new Error( - `[driver-memory] Unrecognized filter element of type ` + + throw unsupportedFilterError( + `Unrecognized filter element of type ` + `"${item === null ? 'null' : typeof item}" — expected a logical keyword ` + `("and"/"or") or a condition array. Filter was: ${JSON.stringify(filters)}`, ); @@ -874,16 +893,16 @@ export class InMemoryDriver implements IDataDriver { : { $gte: store(value[0]), $lte: store(value[1]) }, }; } - throw new Error( - `[driver-memory] "between" on field "${field}" needs a two-element array, got ` + + throw unsupportedFilterError( + `"between" on field "${field}" needs a two-element array, got ` + `${JSON.stringify(value)}. Returning no predicate would silently match every record.`, ); default: // Was `return null`, which the caller dropped — so an operator this // driver cannot express narrowed nothing instead of erroring. driver-sql // already threw on the same input; the two backends disagreed. #3948. - throw new Error( - `[driver-memory] Unsupported filter operator "${operator}" on field "${field}". ` + + throw unsupportedFilterError( + `Unsupported filter operator "${operator}" on field "${field}". ` + `Supported operators: =, !=, <, <=, >, >=, in, nin, between, contains, ` + `not_contains, starts_with, ends_with (see @objectstack/spec VALID_AST_OPERATORS).`, ); diff --git a/packages/plugins/driver-memory/src/memory-filter-refusal-envelope.test.ts b/packages/plugins/driver-memory/src/memory-filter-refusal-envelope.test.ts new file mode 100644 index 0000000000..99052de4e1 --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-filter-refusal-envelope.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4436] The memory driver's filter refusals carry the SAME wire identity as + * driver-sql's. + * + * #3948 made the two backends agree that an uncompilable filter is a refusal + * rather than a silent match-everything. The refusal's ENVELOPE has to agree + * too, or a suite that swaps the memory driver for SQLite sees a coded 400 on + * one backend and a bare `{ error }` on the other — and the cross-driver parity + * this driver exists to provide would be false exactly where it is load-bearing. + * + * Twin of `driver-sql/src/sql-driver-filter-refusal-envelope.test.ts`; the + * rationale lives there. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import type { FilterCondition } from '@objectstack/spec/data'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this filter, but it resolved'); +} + +describe('[#4436] InMemoryDriver filter refusals carry INVALID_FILTER and leak no driver prefix', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver(); + await driver.syncSchema('deal', { + fields: { + id: { type: 'text', name: 'id' }, + stage: { type: 'text', name: 'stage' }, + amount: { type: 'number', name: 'amount' }, + }, + }); + await driver.create('deal', { id: '1', stage: 'won', amount: 10 }); + }); + + const find = (where: unknown) => + driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition }); + + const cases: Array<[string, unknown, string]> = [ + ['unsupported operator in a condition array', [['stage', 'sounds_like', 'won']], 'sounds_like'], + ['bare comparison triple', ['close_date', 'before', '2024-01-01'], 'close_date'], + ['filter element of the wrong type', [42], 'number'], + ['`between` with a bad operand', [['amount', 'between', 5]], 'between'], + ]; + + for (const [name, where, needle] of cases) { + it(`${name} → 400 INVALID_FILTER, no prefix`, async () => { + const err = await refusalOf(() => find(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).not.toContain('[driver-memory]'); + expect(err.message).toContain(needle); + }); + } + + it('a compilable filter is unaffected', async () => { + const rows = await find({ stage: 'won' }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.ts index f1d768025b..b18d5bf658 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-driver.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-driver.ts @@ -212,11 +212,29 @@ export class MongoDBDriver implements IDataDriver { // CRUD Operations // =========================================================================== - async find(object: string, query: QueryAST, options?: DriverOptions): Promise[]> { - const collection = this.getCollection(object); - const session = this.getSession(options); - - const filter = translateFilter(query.where, this.temporalKindFor(object)); + /** + * The projection / sort / pagination half of a read, shared by {@link find} + * and {@link findOne} (objectstack#4419). + * + * It was inline in `find` only, and `findOne` translated `query.where` and + * nothing else — so `orderBy`, `fields` and `offset` were accepted by the + * contract and silently dropped on the way to Mongo. `findOne({ orderBy })` + * therefore did not return the newest record; it returned whichever document + * the collection scan reached first, which is the same + * plausible-looking-wrong-record failure #4419 is about, one layer below the + * engine. + * + * `singleRowLookup` marks the caller as `findOne`; see {@link buildSortSpec}. + * + * Not used by `_findStream`, which deliberately projects the whole document + * regardless of `query.fields` — a separate divergence, and one that returns + * more data rather than the wrong data, so it is left as-is here. + */ + private buildFindOptions( + query: QueryAST, + session: FindOptions['session'], + opts?: { singleRowLookup?: boolean }, + ): FindOptions { const findOptions: FindOptions = { session }; // Field projection @@ -238,13 +256,23 @@ export class MongoDBDriver implements IDataDriver { } // Sorting - const sort = this.buildSortSpec(query); + const sort = this.buildSortSpec(query, opts); if (sort) findOptions.sort = sort; // Pagination if (query.offset !== undefined) findOptions.skip = query.offset; if (query.limit !== undefined) findOptions.limit = query.limit; + return findOptions; + } + + async find(object: string, query: QueryAST, options?: DriverOptions): Promise[]> { + const collection = this.getCollection(object); + const session = this.getSession(options); + + const filter = translateFilter(query.where, this.temporalKindFor(object)); + const findOptions = this.buildFindOptions(query, session); + const cursor = collection.find(filter, findOptions); const results = await cursor.toArray(); return results as Record[]; @@ -255,10 +283,14 @@ export class MongoDBDriver implements IDataDriver { const session = this.getSession(options); const filter = translateFilter(query.where, this.temporalKindFor(object)); - const result = await collection.findOne(filter, { - session, - projection: { _id: 0 }, - }); + // `singleRowLookup`: honour the caller's ordering, impose none of our own — + // the engine sends `limit: 1`, which is indistinguishable from "page one of + // a walk with page size 1", and the two want opposite things + // (objectstack#4363, and `SqlDriver.findRows` for the measured cost). + const result = await collection.findOne( + filter, + this.buildFindOptions(query, session, { singleRowLookup: true }), + ); return result as Record | null; } @@ -630,8 +662,18 @@ export class MongoDBDriver implements IDataDriver { * Returns `undefined` for a read that is neither sorted nor paged — nothing * is being sliced there, so a caller who asked for no order keeps none (the * contract's explicit carve-out). + * + * `singleRowLookup` puts {@link findOne} in that carve-out too. It arrives + * carrying the engine's `limit: 1`, which the `paged` test below cannot tell + * from "page one of a walk with page size 1" — but `findOne` promises *a* + * matching record, never a position in a sequence, so there is no partition + * to preserve and imposing an order only costs the plan the predicate earned + * (objectstack#4363; `SqlDriver.findRows` carries the same flag and the + * measured ~100× regression that motivated it). A caller-supplied `orderBy` + * is still honoured, tie-breaker and all — that is the half this driver used + * to drop entirely (objectstack#4419). */ - private buildSortSpec(query: QueryAST): Document | undefined { + private buildSortSpec(query: QueryAST, opts?: { singleRowLookup?: boolean }): Document | undefined { const sort: Document = {}; let lastDirection: 1 | -1 = 1; if (Array.isArray(query.orderBy)) { @@ -644,7 +686,8 @@ export class MongoDBDriver implements IDataDriver { } const requested = Object.keys(sort).length > 0; - const paged = query.limit !== undefined || query.offset !== undefined; + const paged = + !opts?.singleRowLookup && (query.limit !== undefined || query.offset !== undefined); if (!requested && !paged) return undefined; const idKey = this.mapFieldName('id'); diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-cases.ts b/packages/plugins/driver-mongodb/src/mongodb-findone-cases.ts new file mode 100644 index 0000000000..969922596c --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-findone-cases.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared cases for the two halves of the `findOne` query-execution proof + * (objectstack#4419), so neither half can drift from the other. + * + * The defect being pinned — `MongoDBDriver.findOne` translating `where` and + * dropping `orderBy` / `fields` / `offset` — needs BOTH halves, and each is + * blind to something the other sees: + * + * - `mongodb-findone-options.test.ts` asserts the `FindOptions` the driver + * emits, and executes the cases against a controlled in-process collection. + * It needs no server, so it always runs — which matters, because a driver + * defect only a downloadable binary can catch is a defect nobody catches on + * a restricted network. What it cannot prove is that MongoDB agrees. + * - `mongodb-findone-query.test.ts` runs the same cases against a real mongod, + * and skips when the binary cannot be fetched. It proves the server agrees; + * it proves nothing when it skips. + * + * One table, two readers. An expectation edited on one side moves both. + * + * `b` and `d` deliberately TIE on `rank`: equal sort keys have no defined + * relative order in MongoDB, so a tie is the only place the driver's appended + * `id` tie-breaker is observable in the ROWS at all. + */ + +export interface FindOneRow { + id: string; + name: string; + rank: number; + secret: string; +} + +export const FINDONE_ROWS: readonly FindOneRow[] = [ + { id: 'a', name: 'Alpha', rank: 3, secret: 'sa' }, + { id: 'b', name: 'Bravo', rank: 1, secret: 'sb' }, + { id: 'c', name: 'Charlie', rank: 2, secret: 'sc' }, + { id: 'd', name: 'Delta', rank: 1, secret: 'sd' }, +]; + +export interface FindOneCase { + /** Test name, used verbatim by both suites. */ + name: string; + /** The QueryAST `findOne` receives — the engine always supplies `limit: 1`. */ + query: Record; + /** `id` of the row that must come back, or `null` for a miss. */ + expectId: string | null; + /** + * The `sort` the driver must put on the wire. `undefined` asserts that NO + * ordering was imposed — the #4363 carve-out that makes `findOne` keep the + * plan its predicate earned. Only the options suite can check this; it is not + * observable in the rows. + */ + expectSort: Record | undefined; + /** Keys the returned row must have exactly, when the case projects. */ + expectKeys?: string[]; + /** `skip` the driver must put on the wire, when the case paginates. */ + expectSkip?: number; +} + +export const FINDONE_CASES: readonly FindOneCase[] = [ + // ── orderBy: the half that used to vanish entirely ────────────────── + { + name: 'orderBy rank asc returns the lowest-ranked row, not an arbitrary one', + query: { orderBy: [{ field: 'rank', order: 'asc' }], limit: 1 }, + expectId: 'b', + expectSort: { rank: 1, id: 1 }, + }, + { + name: 'orderBy rank desc returns the highest-ranked row', + query: { orderBy: [{ field: 'rank', order: 'desc' }], limit: 1 }, + expectId: 'a', + expectSort: { rank: -1, id: -1 }, + }, + { + name: 'orderBy composes with a predicate — the first of the MATCHING rows', + query: { where: { id: { $in: ['a', 'c'] } }, orderBy: [{ field: 'rank', order: 'asc' }], limit: 1 }, + expectId: 'c', // rank 2 < rank 3 + expectSort: { rank: 1, id: 1 }, + }, + + // ── the id tie-breaker, in the LAST requested direction ───────────── + { + name: 'a tie on the sort key is broken by id, ascending', + query: { where: { rank: 1 }, orderBy: [{ field: 'rank', order: 'asc' }], limit: 1 }, + expectId: 'b', + expectSort: { rank: 1, id: 1 }, + }, + { + name: 'a tie on the sort key is broken by id, descending', + query: { where: { rank: 1 }, orderBy: [{ field: 'rank', order: 'desc' }], limit: 1 }, + expectId: 'd', + expectSort: { rank: -1, id: -1 }, + }, + + // ── fields / offset ───────────────────────────────────────────────── + { + name: 'fields projects — a column not asked for does not come back', + query: { where: { id: 'a' }, fields: ['name'], limit: 1 }, + expectId: 'a', + expectSort: undefined, + expectKeys: ['id', 'name'], // `id` always kept, `_id` never + }, + { + name: 'offset skips, so an ordered walk can step past the first match', + query: { orderBy: [{ field: 'rank', order: 'asc' }], offset: 1, limit: 1 }, + expectId: 'd', // ranks asc → b, d (tied, id asc), c, a + expectSort: { rank: 1, id: 1 }, + expectSkip: 1, + }, + + // ── and the half that must stay ABSENT (#4363) ────────────────────── + { + name: 'an unsorted single-row lookup imposes no order — its limit: 1 is not a page', + query: { where: { id: 'a' }, limit: 1 }, + expectId: 'a', + expectSort: undefined, + }, + { + name: 'a miss is still null', + query: { where: { id: 'nope' }, limit: 1 }, + expectId: null, + expectSort: undefined, + }, +]; diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-options.test.ts b/packages/plugins/driver-mongodb/src/mongodb-findone-options.test.ts new file mode 100644 index 0000000000..73f84aa112 --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-findone-options.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#4419 — what `MongoDBDriver.findOne` puts on the wire, asserted + * without a server. + * + * The defect this pins was invisible in the rows: `findOne` used to call + * `collection.findOne(filter, { session, projection: { _id: 0 } })` — no + * `sort`, no `skip`, no field projection — so `orderBy`, `offset` and `fields` + * were dropped between the contract and the wire. An untouched four-document + * collection comes back in insertion order every time, which happens to be + * stable, so a row-level assertion can pass against a driver that sorts nothing + * at all. The `FindOptions` object is where the bug is legible. + * + * It runs the shared {@link FINDONE_CASES} twice over: + * + * 1. **What was emitted** — the `sort` / `projection` / `skip` handed to + * Mongo, including the cases whose expectation is that NO sort was imposed + * (#4363), which is not observable in rows at all. + * 2. **What those options select** — the same cases executed against an + * in-process collection that applies them by MongoDB's documented order + * (sort → skip → limit → project). This is what makes the expected ids + * falsifiable HERE rather than only on a CI runner that can download a + * mongod, and it is deliberately narrow: it is checking the case table, not + * standing in for the server. `mongodb-findone-query.test.ts` runs the same + * table against a real mongod for that. + * + * A stand-in more permissive than the real engine turns a suite into a green + * light for broken code — the hazard #4419 itself calls out. Hence the split: + * this file never decides whether the driver is correct, only whether the + * options and the expectations agree. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MongoDBDriver } from './mongodb-driver.js'; +import { FINDONE_CASES, FINDONE_ROWS, type FindOneRow } from './mongodb-findone-cases.js'; + +interface Seen { filter: any; options: any } + +/** Apply a Mongo sort spec (ordered keys, ±1) to a row list. */ +function applySort(rows: FindOneRow[], sort: Record | undefined): FindOneRow[] { + if (!sort) return rows; + return [...rows].sort((x, y) => { + for (const [key, dir] of Object.entries(sort)) { + const a = (x as any)[key]; + const b = (y as any)[key]; + if (a === b) continue; + return (a < b ? -1 : 1) * (dir as number); + } + return 0; + }); +} + +/** The `where` shapes {@link FINDONE_CASES} uses, as Mongo would match them. */ +function matches(row: FindOneRow, filter: any): boolean { + for (const [key, cond] of Object.entries(filter ?? {})) { + const value = (row as any)[key]; + if (cond && typeof cond === 'object' && '$in' in (cond as any)) { + if (!(cond as any).$in.includes(value)) return false; + } else if (value !== cond) { + return false; + } + } + return true; +} + +/** + * A driver wired to a fake `Db` — no connect(), no server. `getCollection` is + * `this.db.collection(name)`, so replacing `db` is enough to observe every call + * the real code path makes. (Spying on a Collection does NOT work: the real + * `getCollection` builds a fresh instance per call, which is what made the + * first version of this suite pass locally and fail on CI.) + */ +function makeDriver(rows: readonly FindOneRow[] = FINDONE_ROWS) { + const seen: { findOne: Seen[]; find: Seen[] } = { findOne: [], find: [] }; + const run = (filter: any, options: any) => { + let out = applySort(rows.filter((r) => matches(r, filter)), options?.sort); + if (typeof options?.skip === 'number') out = out.slice(options.skip); + if (typeof options?.limit === 'number') out = out.slice(0, options.limit); + const projection = options?.projection ?? {}; + const keep = Object.keys(projection).filter((k) => projection[k] === 1); + if (keep.length === 0) return out.map((r) => ({ ...r })); + return out.map((r) => Object.fromEntries(keep.map((k) => [k, (r as any)[k]]))); + }; + const collection = { + findOne: async (filter: any, options: any) => { + seen.findOne.push({ filter, options }); + return run(filter, { ...options, limit: 1 })[0] ?? null; + }, + find: (filter: any, options: any) => { + seen.find.push({ filter, options }); + const out = run(filter, options); + return { toArray: async () => out }; + }, + }; + const driver = new MongoDBDriver({ url: 'mongodb://unused/', database: 'unused' }); + (driver as any).db = { collection: () => collection }; + return { driver, seen }; +} + +describe('MongoDBDriver.findOne hands Mongo the whole query (#4419)', () => { + let driver: MongoDBDriver; + let seen: { findOne: Seen[]; find: Seen[] }; + + beforeEach(() => { + const made = makeDriver(); + driver = made.driver; + seen = made.seen; + }); + + const lastFindOne = () => seen.findOne.at(-1)!; + + // ── 1. what the driver emitted ────────────────────────────────────── + + for (const c of FINDONE_CASES) { + it(`emits the right options — ${c.name}`, async () => { + await driver.findOne('account', { object: 'account', ...c.query } as any); + const { options } = lastFindOne(); + + // `undefined` here is a real assertion, not an absent one: it is the + // #4363 carve-out (no order imposed on an unsorted single-row lookup). + expect(options.sort).toEqual(c.expectSort); + expect(options.skip).toBe(c.expectSkip); + + if (c.expectKeys) { + const projected = Object.entries(options.projection ?? {}) + .filter(([, v]) => v === 1) + .map(([k]) => k); + expect(new Set(projected)).toEqual(new Set(c.expectKeys)); + } + expect(options.projection?._id).toBe(0); // never leaks Mongo's own key + }); + } + + it('translates the predicate, as it always did', async () => { + await driver.findOne('account', { object: 'account', where: { id: 'a' }, limit: 1 } as any); + expect(lastFindOne().filter).toMatchObject({ id: 'a' }); + }); + + it('the transaction session still rides through', async () => { + const session = { id: 'sess-1' } as any; + await driver.findOne( + 'account', + { object: 'account', where: { id: 'a' }, limit: 1 } as any, + { transaction: session } as any, + ); + expect(lastFindOne().options.session).toBe(session); + }); + + // ── 2. what those options select ──────────────────────────────────── + + for (const c of FINDONE_CASES) { + it(`selects the right row — ${c.name}`, async () => { + const row = await driver.findOne('account', { object: 'account', ...c.query } as any); + expect(row === null ? null : String((row as any).id)).toBe(c.expectId); + if (c.expectKeys && row) expect(new Set(Object.keys(row))).toEqual(new Set(c.expectKeys)); + }); + } + + // ── find() is unchanged: the carve-out is findOne-only ────────────── + + it('an unordered PAGED find still gets a deterministic order imposed', async () => { + await driver.find('account', { object: 'account', limit: 2, offset: 0 } as any); + expect(seen.find.at(-1)!.options.sort).toEqual({ id: 1 }); + }); + + it('an unordered, unpaged find still gets none — that rule is unchanged', async () => { + await driver.find('account', { object: 'account' } as any); + expect(seen.find.at(-1)!.options.sort).toBeUndefined(); + }); +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts b/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts new file mode 100644 index 0000000000..10af39255e --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#4419 — `MongoDBDriver.findOne` executes the whole QueryAST + * against a REAL mongod, not just its `where`. + * + * It used to issue `collection.findOne(translateFilter(query.where), { + * projection: { _id: 0 } })` and nothing else: `orderBy`, `fields` and `offset` + * were accepted by the contract and dropped on the floor. `find` and + * `_findStream` in the same file had always handled all three, so this was a + * per-method divergence exactly like the engine-level one #4419 is about — + * `findOne({ orderBy: [{ field: 'created_at', order: 'desc' }] })` did not + * return the newest record, it returned whichever document the scan reached + * first, with no error and nothing to grep for. + * + * It matters beyond Mongo: the engine's own findOne guard tells a caller with + * no predicate to pass `orderBy` instead ("the first record in THIS order"). An + * escape hatch one backend silently ignores is not an escape hatch. + * + * The cases come from {@link FINDONE_CASES}, shared with + * `mongodb-findone-options.test.ts` so the two halves cannot drift. This half + * answers the one question the other cannot: does MongoDB agree? It therefore + * asserts ROWS only — the "no sort was imposed" cases are checked over there, + * because an untouched collection comes back in insertion order whether or not + * a sort went out. + * + * Skips itself when the mongod binary cannot be fetched — the convention the + * other suites in this package already use. A skip is not a pass. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { MongoDBDriver } from './mongodb-driver.js'; +import { FINDONE_CASES, FINDONE_ROWS } from './mongodb-findone-cases.js'; + +let sharedMongod: MongoMemoryServer | undefined; +try { + sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } }); +} catch (err) { + console.warn( + '[driver-mongodb] Skipping findOne query-execution suite — mongodb-memory-server could not ' + + `start: ${(err as Error)?.message ?? String(err)}`, + ); +} + +describe.skipIf(!sharedMongod)('driver-mongodb — findOne executes the whole query', () => { + const mongod = sharedMongod as MongoMemoryServer; + let driver: MongoDBDriver; + + beforeAll(async () => { + driver = new MongoDBDriver({ url: mongod.getUri(), database: 'findone_query' }); + await driver.connect(); + for (const row of FINDONE_ROWS) await driver.create('account', { ...row }); + }, 90_000); + + afterAll(async () => { + if (driver) await driver.disconnect(); + if (sharedMongod) await sharedMongod.stop(); + }); + + for (const c of FINDONE_CASES) { + it(c.name, async () => { + const row = await driver.findOne('account', { object: 'account', ...c.query } as any); + expect(row === null ? null : String((row as any).id)).toBe(c.expectId); + if (c.expectKeys && row) expect(new Set(Object.keys(row))).toEqual(new Set(c.expectKeys)); + }); + } + + it('find() is unchanged — a paged walk is still a partition of the rows', async () => { + const seen: string[] = []; + for (let offset = 0; offset < FINDONE_ROWS.length; offset += 2) { + const page = await driver.find('account', { object: 'account', limit: 2, offset } as any); + seen.push(...page.map((r) => String(r.id))); + } + expect(seen).toHaveLength(FINDONE_ROWS.length); + expect(new Set(seen).size).toBe(FINDONE_ROWS.length); // none served twice, none missed + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts b/packages/plugins/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts new file mode 100644 index 0000000000..4ba9bcb56c --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4436] The uncompilable-filter refusal must carry an ADR-0112 wire identity. + * + * #4209/#4029/#3948 settled the POSTURE — a filter the driver cannot compile is + * refused instead of silently matching every row. What was still missing is the + * refusal's IDENTITY on the wire. + * + * The driver threw a bare `Error`, so it carried no `code` and no `status`. + * `@objectstack/rest`'s `mapDataError` therefore fell all the way through to its + * default branch — `{ status: 400, body: { error: raw } }` — and served a body + * whose only key was `error`: + * + * ``` + * GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}} + * → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"} + * ``` + * + * Two contract breaks in one body: no `error.code` at all, on a route whose + * sibling rejections all speak the catalogued vocabulary (`INVALID_FIELD`, + * `INVALID_FILTER`, `RECORD_NOT_FOUND`); and the driver-internal `[sql-driver]` + * prefix on the wire, which is precisely what the #3867 sanitiser exists to + * stop. + * + * These tests pin BOTH halves at the throw site, because that is where the fix + * lives (PD #12 — the producer declares its own refusal; the REST layer is not + * patched to guess). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import type { FilterCondition } from '@objectstack/spec/data'; + +/** The shape `mapDataError` / `sendError` read off a thrown driver error. */ +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this filter, but it resolved'); +} + +describe('[#4436] SqlDriver filter refusals carry INVALID_FILTER and leak no driver prefix', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: 'deal', + fields: { + id: { type: 'text', name: 'id' }, + stage: { type: 'text', name: 'stage' }, + amount: { type: 'number', name: 'amount' }, + }, + } as any, + ]); + await driver.create('deal', { id: '1', stage: 'won', amount: 10 }); + }); + + const find = (where: unknown) => + driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition }); + + // The issue's own repro, at the layer that produces the envelope. + it('the $-object unsupported-operator branch — the exact shape #4436 reported', async () => { + const err = await refusalOf(() => find({ stage: { $bogusop: 'x' } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).not.toContain('[sql-driver]'); + // The actionable half survives the sanitisation — a caller must still be + // able to see WHICH operator on WHICH field, and what is accepted instead. + expect(err.message).toContain('$bogusop'); + expect(err.message).toContain('stage'); + expect(err.message).toContain('$startsWith'); + }); + + // Every filter-COMPILATION refusal in this driver answers the same way. They + // are one condition — "this filter cannot run" — and ADR-0112's rule is one + // condition, one wire code, however the caller reached it. + const cases: Array<[string, unknown, string]> = [ + ['legacy triple, unsupported operator', [['stage', 'sounds_like', 'won']], 'sounds_like'], + ['bare comparison triple', ['close_date', 'before', '2024-01-01'], 'close_date'], + ['filter element of the wrong type', [42], 'number'], + ['null filter element', [null], 'null'], + ['legacy `between` with a bad operand', [['amount', 'between', 5]], 'between'], + ['$between with a bad operand', { amount: { $between: 5 } }, '$between'], + ]; + + for (const [name, where, needle] of cases) { + it(`${name} → 400 INVALID_FILTER, no prefix`, async () => { + const err = await refusalOf(() => find(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).not.toContain('[sql-driver]'); + expect(err.message).toContain(needle); + }); + } + + it('a compilable filter is unaffected', async () => { + const rows = await find({ stage: 'won' }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 5f13a8a727..1e52625c4c 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -12,6 +12,7 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyD import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; import { canonicalAstOperator } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; +import { StandardErrorCode } from '@objectstack/spec/api'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; import { resolveMultiOrgEnabled } from '@objectstack/types'; @@ -378,6 +379,40 @@ function canonicalTimeOfDay(value: unknown): unknown { */ const SQLITE_TIME_EXPR_REFS = 8; +/** + * [#4436] A filter this driver cannot COMPILE — the caller sent an operator (or + * an operand shape) outside what the backend can express. + * + * This is a refusal the request caused, and #4209/#4029 already made it a + * refusal rather than a silent match-everything. What was missing is the wire + * IDENTITY of that refusal: the thrown `Error` carried no `code`, so + * `mapDataError`'s default branch served `{ "error": "" }` with no + * `code` at all — breaking the ADR-0112 contract that `error.code` is the + * schema-enforced SCREAMING_SNAKE vocabulary every sibling rejection on this + * route already speaks (`INVALID_FIELD`, `INVALID_FILTER`, `RECORD_NOT_FOUND`). + * + * `INVALID_FILTER` is the catalogued code for the condition, and the SAME one + * `metadata-protocol` emits for a filter that fails to parse upstream + * (`malformedFilterArrayError` / `unusableFilterError`): one condition — "this + * filter cannot run" — has one wire code however the caller reached it. + * + * `status: 400` makes `@objectstack/rest`'s `sendError` pass the message + * through instead of routing it to the SQL-leak heuristic, and puts the + * rejection on the `isExpectedQueryRejection` list so a client mistake stops + * being logged as an unhandled server error. + * + * The `[sql-driver]` prefix these messages used to carry is GONE from the text: + * it is driver-internal wording, and shipping it to clients is exactly what the + * #3867 sanitiser exists to stop. The operator/field/vocabulary detail — the + * part a caller can act on — stays. + */ +function unsupportedFilterError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + return err; +} + // ── Introspection Types ────────────────────────────────────────────────────── export interface IntrospectedColumn { @@ -1349,9 +1384,12 @@ export class SqlDriver implements IDataDriver { * bought for that: `findOne` promises *a* matching record, never a position * in a sequence, so there is no partition to preserve. * - * That also puts this driver back in step with `MongoDBDriver.findOne`, which - * issues `collection.findOne` and has never sorted. The obligation the - * contract states is on `find`, and this keeps it there. + * `MongoDBDriver.findOne` carries the same flag into its own `buildSortSpec`, + * so both drivers now read a `findOne` the same way: honour the caller's + * `orderBy`, impose nothing when there is none. (Mongo used to translate + * `where` and drop `orderBy` outright — an earlier version of this comment + * cited that as agreement, which it was not; objectstack#4419.) The + * obligation the contract states is on `find`, and this keeps it there. */ private async findRows( object: string, @@ -5077,8 +5115,8 @@ export class SqlDriver implements IDataDriver { // never converted it and the raw array arrived as `where`. Skipping it // (the old behaviour) emitted NO predicate at all: the caller asked to // filter and silently got every row. Fail loudly instead. #3948. - throw new Error( - `[sql-driver] Unrecognized filter operator "${item}" in a comparison triple. ` + + throw unsupportedFilterError( + `Unrecognized filter operator "${item}" in a comparison triple. ` + `A filter array is either a logical node (["and"|"or", …]) or nested ` + `conditions ([[field, op, value], …]); a bare [field, op, value] only ` + `reaches the driver when its operator is outside @objectstack/spec ` + @@ -5133,8 +5171,8 @@ export class SqlDriver implements IDataDriver { // branches and was dropped, so a malformed element silently narrowed // nothing. Same reasoning as above: an unapplied filter must not look // like a satisfied one. #3948. - throw new Error( - `[sql-driver] Unrecognized filter element of type "${item === null ? 'null' : typeof item}" — ` + + throw unsupportedFilterError( + `Unrecognized filter element of type "${item === null ? 'null' : typeof item}" — ` + `expected a logical keyword ("and"/"or") or a condition array. ` + `Filter was: ${JSON.stringify(filters)}`, ); @@ -5256,7 +5294,7 @@ export class SqlDriver implements IDataDriver { case 'between': { const arr = Array.isArray(coerced) ? coerced : []; if (arr.length !== 2) { - throw new Error(`[sql-driver] operator "between" on field "${field}" requires a [min, max] value array.`); + throw unsupportedFilterError(`Operator "between" on field "${field}" requires a [min, max] value array.`); } builder[join === 'or' ? 'orWhereBetween' : 'whereBetween'](field, arr as [any, any]); return; @@ -5295,8 +5333,8 @@ export class SqlDriver implements IDataDriver { builder[whereNotNull](field); return; default: - throw new Error( - `[sql-driver] Unsupported filter operator "${op}" on field "${field}". Supported operators: ` + + throw unsupportedFilterError( + `Unsupported filter operator "${op}" on field "${field}". Supported operators: ` + `=, !=, <, <=, >, >=, in, nin, between, contains, not_contains, starts_with, ends_with, ` + `is_null, is_not_null (see @objectstack/spec VALID_AST_OPERATORS).`, ); @@ -5447,7 +5485,7 @@ export class SqlDriver implements IDataDriver { case '$between': { const arr = Array.isArray(coerced) ? coerced : []; if (arr.length !== 2) { - throw new Error(`[sql-driver] operator "$between" on field "${field}" requires a [min, max] value array.`); + throw unsupportedFilterError(`Operator "$between" on field "${field}" requires a [min, max] value array.`); } (builder as any)[logicalOp === 'or' ? 'orWhereBetween' : 'whereBetween'](field, arr as [any, any]); break; @@ -5469,8 +5507,8 @@ export class SqlDriver implements IDataDriver { : (logicalOp === 'or' ? 'orWhereNotNull' : 'whereNotNull')](field); break; default: - throw new Error( - `[sql-driver] Unsupported filter operator "${op}" on field "${field}". Supported operators: ` + + throw unsupportedFilterError( + `Unsupported filter operator "${op}" on field "${field}". Supported operators: ` + `$eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, ` + `$startsWith, $endsWith, $regex, $null, $exists.`, ); diff --git a/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts b/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts new file mode 100644 index 0000000000..6896f0e680 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An admin override of a STAFFED approver slate is recorded as an override (#4466). + * + * The reproduction is the issue's: `showcase_dynamic_approval` stage 2 resolved + * its `expression` approvers correctly to exactly one designated user, and the + * admin — who was not in that slate — approved it anyway through the #3424 + * privileged path. The designated approver then got `409 INVALID_STATE`. + * + * The override itself is defensible; what was not is the audit trail. Before + * this, `sys_approval_action` had no override column at all, so an admin + * overriding a properly-staffed slate wrote a row byte-for-byte identical to the + * designated approver approving normally. A reader of the timeline saw `approve` + * by the admin and could not tell which had happened, and the bypassed + * approver's 409 was the only trace — existing only if they happened to try. + * + * The platform KNOWS at decision time: it took the `isOverrideActor` branch to + * admit the call. This was dropped information, not unavailable information. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ApprovalService } from './approval-service.js'; + +interface FakeRow { [k: string]: any } + +/** The same minimal engine shape `approval-service.test.ts` uses. */ +function makeFakeEngine() { + const tables: Record = {}; + const ensure = (n: string) => (tables[n] ??= []); + function matches(row: FakeRow, filter: any): boolean { + if (!filter || typeof filter !== 'object') return true; + for (const [k, v] of Object.entries(filter)) { + if (k === '$or') { + if (!(v as any[]).some(sub => matches(row, sub))) return false; + continue; + } + const rv = row[k]; + if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(rv)) return false; + continue; + } + if (rv !== v) return false; + } + return true; + } + return { + _tables: tables, + async find(object: string, options?: any) { + const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); + if (options?.orderBy?.[0]) { + const { field, order } = options.orderBy[0]; + rows.sort((a, b) => { + const av = a[field]; const bv = b[field]; + if (av === bv) return 0; + const cmp = av > bv ? 1 : -1; + return order === 'desc' ? -cmp : cmp; + }); + } + const start = options?.offset ?? 0; + return rows.slice(start, start + (options?.limit ?? 1000)); + }, + async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; }, + async update(object: string, idOrData: any, _opts?: any) { + const data = typeof idOrData === 'object' ? idOrData : _opts; + const id = typeof idOrData === 'object' ? idOrData.id : idOrData; + const table = ensure(object); + const i = table.findIndex(r => r.id === id); + if (i >= 0) table[i] = { ...table[i], ...data }; + return table[i]; + }, + async delete(object: string, options?: any) { + const table = ensure(object); + const i = table.findIndex(r => r.id === (options?.where?.id ?? options?.id)); + if (i >= 0) table.splice(i, 1); + return {}; + }, + registerHook() {}, unregisterHooksByPackage() { return 0; }, async fire() {}, + }; +} + +const SYS = { isSystem: true, positions: [], permissions: [] } as any; +const SUBMITTER = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any; +/** The designated approver the slate actually names. */ +const DESIGNATED = { userId: 'u9', tenantId: 't1', positions: [], permissions: [] } as any; +/** An admin who is NOT in the slate — the issue's actor. */ +const ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any; +/** An admin who IS a designated approver — approving normally, not overriding. */ +const ADMIN_ON_SLATE = { userId: 'u9', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any; + +describe('approval override audit marker (#4466)', () => { + let engine: ReturnType; + let svc: ApprovalService; + let n = 0; + const baseTime = new Date('2026-01-15T10:00:00Z').getTime(); + + beforeEach(() => { + engine = makeFakeEngine(); + n = 0; + svc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(baseTime + (n++) * 1000) }, + }); + }); + + /** A request whose slate is properly STAFFED — one real, designated user. */ + const staffedInput = (extra: Record = {}) => ({ + object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'co_sign', + flowName: 'showcase_dynamic_approval', + config: { + approvers: [{ type: 'user' as const, value: 'u9' }], + behavior: 'first_response' as const, lockRecord: true, + }, + record: { id: 'opp1', amount: 100 }, + ...extra, + }); + + it('the repro: the slate names exactly one designated user, and the admin is not on it', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + expect(req.pending_approvers).toEqual(['u9']); + expect(req.pending_approvers).not.toContain('root'); + }); + + it('marks an admin override of a STAFFED slate as `via_override`', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'root', comment: 'not mine' }, ADMIN); + + const acts = await svc.listActions(req.id, SYS); + const decision = acts.at(-1)!; + expect(decision).toMatchObject({ action: 'approve', actor_id: 'root', comment: 'not mine' }); + // The bit the audit trail used to drop entirely. + expect(decision.via_override).toBe(true); + }); + + it('does NOT mark the designated approver’s own approval', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, DESIGNATED); + + const decision = (await svc.listActions(req.id, SYS)).at(-1)!; + expect(decision).toMatchObject({ action: 'approve', actor_id: 'u9' }); + // An explicit `false`, not absent: "checked, and it was not an override" is + // a different claim from a legacy row's "not recorded". + expect(decision.via_override).toBe(false); + }); + + it('does NOT mark an admin who is ALSO a designated approver — that is an ordinary approval', async () => { + // The marker is about which BRANCH admitted the call, not about whether the + // actor happens to hold admin rights. Collapsing the two would make every + // admin's ordinary decision read as a slate bypass. + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, ADMIN_ON_SLATE); + + expect((await svc.listActions(req.id, SYS)).at(-1)!.via_override).toBe(false); + }); + + it('marks an override REJECTION too — the direction of the decision is irrelevant', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.decideNode(req.id, { decision: 'reject', actorId: 'root' }, ADMIN); + + const decision = (await svc.listActions(req.id, SYS)).at(-1)!; + expect(decision).toMatchObject({ action: 'reject', via_override: true }); + }); + + it('the override and the ordinary approval are no longer identical rows', async () => { + // The issue's core claim, asserted directly: before the column, these two + // decisions differed only in `actor_id` — and an `actor_id` alone cannot + // answer "were they entitled to?". + const a = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.decideNode(a.id, { decision: 'approve', actorId: 'root' }, ADMIN); + const overrideRow = (await svc.listActions(a.id, SYS)).at(-1)!; + + const b = await svc.openNodeRequest( + staffedInput({ recordId: 'opp2', runId: 'run_2', record: { id: 'opp2', amount: 100 } }), + SUBMITTER, + ); + await svc.decideNode(b.id, { decision: 'approve', actorId: 'u9' }, DESIGNATED); + const normalRow = (await svc.listActions(b.id, SYS)).at(-1)!; + + expect(overrideRow.action).toBe(normalRow.action); + expect(overrideRow.via_override).not.toBe(normalRow.via_override); + }); + + it('marks an admin RESCUE reassign of a slate they hold no slot in', async () => { + // #3424's other privileged action: handing a stuck (or staffed) request to + // a real approver. Same fact, same column. + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.reassign(req.id, { actorId: 'root', to: 'u7' }, ADMIN); + + const row = (await svc.listActions(req.id, SYS)).at(-1)!; + expect(row).toMatchObject({ action: 'reassign', reassign_to: 'u7', via_override: true }); + }); + + it('does NOT mark a slot holder handing over their own slot', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, DESIGNATED); + + expect((await svc.listActions(req.id, SYS)).at(-1)!.via_override).toBe(false); + }); + + it('a legacy row (written before the column existed) reads as UNRECORDED, not as "not an override"', async () => { + const req = await svc.openNodeRequest(staffedInput(), SUBMITTER); + // Simulate a row persisted by an older build: the column is simply absent. + const rows = engine._tables['sys_approval_action']; + delete rows[rows.length - 1].via_override; + + const row = (await svc.listActions(req.id, SYS)).at(-1)!; + expect(row.via_override).toBeUndefined(); + expect(row.via_override).not.toBe(false); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts b/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts new file mode 100644 index 0000000000..cfd80dba73 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approval decisions across a process restart (#4420). + * + * The reported failure: a flow parks at an `approval` node in process A, the + * process restarts, and the approver clicks Approve in process B. The request + * row flips to `approved`, the UI toasts success — and the flow never moves. + * The next stage's request is never opened, the record's mirrored status + * freezes mid-workflow, and nothing anywhere logs an error. Approval flows + * pause for days by design, so a deploy in the middle is the normal case, not + * the edge one: every release could silently zombify every in-flight approval. + * + * Two independent defects produced it, and both are pinned here: + * + * 1. The run state has to SURVIVE the restart (#1518's durable store, driven + * end to end through the approvals surface for the first time — the store's + * own suite proves the engine half, not that a decision can cross it). + * 2. When it did not survive, every layer read the failure as success: + * `engine.resume()` REPORTS `{ success: false }` rather than throwing, + * `serviceResume` discarded that return value, and `decide()` counted only + * a thrown error as failure — so it answered `resumed: true`, HTTP 200. + * + * The fix refuses the decision instead: the run is checked BEFORE the decision + * is written, so the zombie half-state (recorded decision + stranded run) is + * never created rather than merely reported. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; +import { ApprovalService } from './approval-service.js'; +import { registerApprovalNode } from './approval-node.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; +const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; + +/** In-memory ObjectQL stand-in — the approvals tables outlive the "restart". */ +function makeFakeEngine() { + const tables = new Map(); + const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); + const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne; + return row[k] === v; + }); + return { + tables, + async find(object: string, opts: any = {}) { + const where = opts.where ?? opts.filter ?? {}; + let out = rows(object).filter(r => matches(r, where)); + if (opts.limit) out = out.slice(0, opts.limit); + return out.map(r => ({ ...r })); + }, + async insert(object: string, data: any) { rows(object).push({ ...data }); return { ...data }; }, + async update(object: string, idOrData: any) { + const row = rows(object).find(r => r.id === idOrData.id); + if (row) Object.assign(row, idOrData); + return row ? { ...row } : null; + }, + async delete(object: string, opts: any = {}) { + const list = rows(object); + for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], opts.where ?? {})) list.splice(i, 1); + return { affected: 1 }; + }, + }; +} + +function registerDecisionFlow(engine: AutomationEngine) { + engine.registerFlow('deal_approval', { + name: 'deal_approval', + label: 'Deal Approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers: [{ type: 'user', value: 'u1' }] } }, + { id: 'on_approved', type: 'mark', label: 'Approved' }, + { id: 'on_rejected', type: 'mark', label: 'Rejected' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approve_step' }, + { id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' }, + { id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' }, + { id: 'e4', source: 'on_approved', target: 'end' }, + { id: 'e5', source: 'on_rejected', target: 'end' }, + ], + } as never); +} + +describe('approval decisions across a process restart (#4420)', () => { + let data: ReturnType; + let service: ApprovalService; + let marks: string[]; + + /** + * One process lifetime: a fresh engine over the shared approvals tables, + * optionally sharing a durable suspended-run store with earlier lifetimes. + * Nothing carries over in memory — that is the whole point. + */ + function boot(store?: InMemorySuspendedRunStore) { + const automation = new AutomationEngine(noopLogger as any, store); + registerApprovalNode(automation, service, noopLogger as any); + automation.registerNodeExecutor({ + type: 'mark', + async execute(node: any) { marks.push(node.id); return { success: true }; }, + }); + registerDecisionFlow(automation); + service.attachAutomation(automation); + return automation; + } + + const pendingRequest = async () => + (await data.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + + beforeEach(() => { + marks = []; + data = makeFakeEngine(); + service = new ApprovalService({ engine: data as any, logger: noopLogger }); + }); + + it('approves a run that paused in a previous process, and the flow advances', async () => { + // #1518's promise, exercised the way a user reaches it: submit in one + // process, decide in the next. + const store = new InMemorySuspendedRunStore(); + const processA = boot(store); + const paused = await processA.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + expect(paused.status).toBe('paused'); + const request = await pendingRequest(); + + // ── restart ── nothing of process A survives except the two stores. + const processB = boot(store); + expect(processB.listSuspendedRuns(), 'no in-memory state carried over').toHaveLength(0); + + const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + + expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true }); + expect(marks, 'the flow continued down the approve branch').toEqual(['on_approved']); + expect((await data.find('sys_approval_request', { where: { id: request.id } }))[0].status).toBe('approved'); + }); + + it('refuses the decision, writing nothing, when the run did not survive the restart', async () => { + // Process A keeps its pauses in memory only — the 17.0.0-rc.1 deployment. + const processA = boot(); + await processA.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + const request = await pendingRequest(); + + // ── restart ── the suspension is gone for good. + boot(); + + await expect( + service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX), + ).rejects.toThrow(/RESUME_TARGET_LOST/); + + // The half-state is not merely reported — it is never created. The request + // is still actionable once an operator sorts the run out, and the audit + // trail does not claim an approval that had no effect. + const after = (await data.find('sys_approval_request', { where: { id: request.id } }))[0]; + expect(after.status, 'still pending, not a zombie "approved"').toBe('pending'); + expect(after.pending_approvers).toContain('u1'); + expect( + await data.find('sys_approval_action', { where: { request_id: request.id, action: 'approve' } }), + 'no approval was audited for a decision that could not take effect', + ).toHaveLength(0); + expect(marks).toEqual([]); + }); + + it('names the stranded run when the resume fails after the decision was written', async () => { + // The residual race: the run passes the pre-flight and dies before the + // resume. The decision IS durable by then, so this cannot be undone — but + // it must not read as success, and it must name what needs rescuing. + const processA = boot(); + await processA.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + const req = await pendingRequest(); + + service.attachAutomation({ + hasSuspendedRun: async () => true, + resume: async () => ({ success: false, code: 'RUN_NOT_FOUND', error: `No suspended run 'run_x'` }), + } as any); + + const err = await service + .decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX) + .then(() => null, (e: Error) => e); + + expect(err?.message).toMatch(/^RESUME_FAILED/); + expect(err?.message, 'says which run an operator has to rescue').toMatch( + /could not be resumed and is now stranded/, + ); + // The decision itself stands — it is durable, and pretending otherwise + // would put the row and the audit trail out of step. + expect((await data.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('approved'); + }); + + it('treats a concurrent duplicate resume as benign, not as a failure', async () => { + const processA = boot(); + await processA.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + const req = await pendingRequest(); + + // The engine's own idempotency guard: another caller is already advancing + // this run. Surfacing that as an error would turn a working safeguard into + // a user-visible failure. + service.attachAutomation({ + hasSuspendedRun: async () => true, + resume: async () => ({ success: false, code: 'RESUME_IN_PROGRESS', error: `Run 'run_x' is already being resumed` }), + } as any); + + const out = await service.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + expect(out.finalized).toBe(true); + expect(out.resumed).toBe(false); + expect(out.resumeError).toMatch(/already being resumed/); + }); + + it('does not block a decision when the suspended-run store is merely unreachable', async () => { + const processA = boot(); + await processA.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + const req = await pendingRequest(); + const realResume = service['automation']!.resume!.bind(service['automation']); + + // A transient outage means "unknown", not "dead". Failing closed here would + // reject every decision in the tenant for the duration of a blip. + service.attachAutomation({ + hasSuspendedRun: async () => { throw new Error('connection refused'); }, + resume: realResume, + } as any); + + const out = await service.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + expect(out).toMatchObject({ finalized: true, resumed: true }); + expect(marks).toEqual(['on_approved']); + }); + + it('leaves a composition with no automation engine exactly as it was', async () => { + // Approvals also runs with no engine attached — the request row still + // names a run, but there is nothing here that could resume it. The + // pre-flight must stay out of the way rather than invent a failure. + const standalone = new ApprovalService({ engine: data as any, logger: noopLogger }); + const opened = await standalone.openNodeRequest({ + object: 'crm_deal', recordId: 'd1', runId: 'run_from_another_process', + nodeId: 'approve_step', config: { approvers: [{ type: 'user', value: 'u1' }] } as any, + }, SYSTEM_CTX); + + const out = await standalone.decide((opened as any).id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + expect(out.finalized).toBe(true); + expect(out.resumed).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index ee49cf5845..8c0a0d559f 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -916,6 +916,43 @@ describe('ApprovalService (node era)', () => { expect(out.resumed).toBe(false); }); + // ── #4420: a REPORTED resume failure is a failure ──────────────── + // + // The engine answers a lost run with `{ success: false }` rather than by + // throwing, so every one of these used to come back as `resumed: true`. + + it('decide: refuses before writing anything when the run is already gone', async () => { + svc.attachAutomation({ + async hasSuspendedRun() { return false; }, + async resume() { return { success: true }; }, + } as any); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + + await expect(svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS)) + .rejects.toThrow(/RESUME_TARGET_LOST/); + const [row] = await engine.find('sys_approval_request', { where: { id: req.id } }); + expect(row.status, 'nothing recorded against a run that cannot advance').toBe('pending'); + }); + + it('decide: fails loudly when a resume reports failure without throwing', async () => { + svc.attachAutomation({ + async resume() { return { success: false, code: 'RUN_NOT_FOUND', error: `No suspended run 'run_1'` }; }, + } as any); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + + await expect(svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS)) + .rejects.toThrow(/RESUME_FAILED/); + }); + + it('decide: a resume that returns nothing still counts as success', async () => { + // The historical shape: an engine (or a test double) that reports nothing + // is reporting no failure, and must not be read as one. + svc.attachAutomation({ async resume() { /* returns undefined */ } }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + expect(out.resumed).toBe(true); + }); + // ── read API ──────────────────────────────────────────────────── it('listRequests: filters by approver and status', async () => { diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 87e9647d88..027b6a49af 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -106,6 +106,21 @@ export interface ApprovalResumeSurface { * as "still alive". */ getRun?(runId: string): Promise<{ status?: string } | null>; + /** + * Whether a suspension still exists for `runId` — the pre-flight that keeps + * a decision from being recorded against a run that can never advance + * (#4420). Read-only; it never consumes the suspension. + * + * Distinct from {@link getRun}, which reports on the execution LOG: a run + * suspended by a PREVIOUS process resolves to `null` there even when its + * state is durable, so it cannot tell "waiting for a human" from "dead". + * This asks the suspension store itself. + * + * Rejects when the durable store cannot be read — existence is then + * unknown, and callers must not read an outage as a dead run. Optional: an + * engine that does not implement it simply gets no pre-flight. + */ + hasSuspendedRun?(runId: string): Promise; } /** @@ -153,6 +168,42 @@ const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ 'completed', 'failed', 'cancelled', 'timed_out', ]); +/** + * Request statuses that can leave a ZOMBIE behind (#4469) — the terminal states + * a decision reaches only by ALSO resuming the owning run. + * + * `recalled` is deliberately absent: a recall ABANDONS the request on purpose, + * and {@link ApprovalService.recall} explicitly tolerates a run it cannot + * resume (the withdrawal and the lock release are the point). Reporting those + * would bury the real findings under expected ones. + */ +const STRANDABLE_REQUEST_STATUSES = ['approved', 'rejected', 'returned'] as const; + +/** + * One terminal request whose owning flow run is unrecoverable (#4469) — the + * decision was recorded and the flow never moved. Reporting shape only: the + * sweep never rewrites these rows (see + * {@link ApprovalService.inspectStrandedRequests}). + */ +export interface StrandedApprovalRequest { + requestId: string; + /** Terminal status the request reached — the decision that WAS recorded. */ + status: string; + /** The `flow_run_id` that resolves to neither a suspension nor a run history row. */ + runId: string; + flowName?: string; + /** Approval node the run should have continued from. */ + nodeId?: string; + objectName: string; + recordId: string; + organizationId?: string | null; + completedAt?: string; + /** `config.approvalStatusField`, when the node mirrors status onto the record. */ + mirrorField?: string; + /** What that mirror field currently reads — usually the stale value an operator sees. */ + mirroredStatus?: string; +} + /** Default lifetime of an actionable-link token (ADR-0043). */ export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000; @@ -438,6 +489,11 @@ function rowFromAction(row: any): ApprovalActionRow { // Structured reassign hand-off parties (#4365). reassign_from: row.reassign_from ?? undefined, reassign_to: row.reassign_to ?? undefined, + // #4466 — surfaced so a timeline can SAY "overridden the approver slate" + // rather than render an override identically to an ordinary approval. + // `null` (a row written before the column existed) stays `undefined`: + // "not recorded" is not the same claim as "not an override". + via_override: row.via_override == null ? undefined : row.via_override === true, // Decision attachments (#3266): rich descriptors carrying the display name + // download URL, so consumers label/open them without reading `sys_file`. attachments: attachments.length ? attachments : undefined, @@ -1649,6 +1705,11 @@ export class ApprovalService implements IApprovalService { if (!isSlotHolder && !isOverride) { throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`); } + // #4466 — the audit fact this decision would otherwise drop: the actor was + // admitted ONLY by the override branch, holding no slot in the staffed + // slate. An admin who IS a slot holder is approving normally, so the two + // conditions are recorded apart rather than collapsed into "actor is admin". + const viaOverride = isOverride && !isSlotHolder; const config = parseJson(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any); const org = raw.organization_id ?? null; @@ -1721,11 +1782,24 @@ export class ApprovalService implements IApprovalService { } } + // The run behind this request must still be resumable before ANY of it is + // written down (#4420). Last of the refusals, first before the writes: a + // decision recorded against a dead run is a zombie nothing later can undo, + // and the approver is told it succeeded. + // + // Non-finalizing votes are checked too — a co-sign that can never reach a + // resume is just as stuck, and catching it here keeps the tally honest. + await this.assertRunResumable(runId, requestId); + // Audit the decision first so the quorum/per_group tally below sees it. await this.engine.insert('sys_approval_action', { id: uid('aact'), request_id: requestId, organization_id: org, step_name: nodeId, step_index: 0, action: input.decision, actor_id: actorId, comment: input.comment ?? null, + // #4466: the override is recorded on the DECISION, not inferred later. + // Written as an explicit `false` for an ordinary decision so a reader can + // tell "checked, and it was not an override" from a legacy row's `null`. + via_override: viaOverride, attachments: input.attachments?.length ? input.attachments : null, created_at: now, }, { context: SYSTEM_CTX }); @@ -1824,18 +1898,130 @@ export class ApprovalService implements IApprovalService { * Callers still guard on `typeof this.automation?.resume === 'function'` * (approvals runs fine with no automation attached) and keep their own * try/catch, because what a failed resume means differs per path. + * + * Throws when the engine REPORTS failure, not only when it throws one. The + * engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }` + * — a plain return value that every caller here used to discard, which is + * how an approval could be recorded, reported as resumed, and leave its flow + * stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s + * `resumeCode` so callers can tell a benign duplicate from a dead run. */ private async serviceResume( runId: string, signal: { output?: Record; branchLabel?: string }, ): Promise { - await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true }); + const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true }); + const reported = result as { success?: boolean; code?: string; error?: string } | undefined; + // Only an explicit `success: false` is a failure. An engine (or a test + // double) that returns nothing is reporting nothing, and has always meant + // "it ran". + if (reported && typeof reported === 'object' && reported.success === false) { + const err = new Error( + `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ''}: ${reported.error ?? 'unknown error'}`, + ) as Error & { resumeCode?: string }; + err.resumeCode = reported.code; + throw err; + } + } + + /** The engine failure code behind a {@link serviceResume} rejection, if any. */ + private static resumeCodeOf(err: unknown): string | undefined { + return (err as { resumeCode?: string } | undefined)?.resumeCode; + } + + /** + * Refuse an operation whose whole point is to advance a flow run when that + * run no longer exists — BEFORE anything is written down (#4420). + * + * The half-state this prevents is the one the issue reported: a request + * flipped to `approved`, a success toast, and a flow that never moves. Once + * the decision row is written there is nothing left to fail cleanly. + * + * Deliberately permissive at the edges: + * - no automation attached, or an engine without `hasSuspendedRun` → no + * pre-flight at all (standalone approvals compositions are unaffected); + * - the store cannot be READ → fail OPEN. A transient outage must not block + * every decision in the tenant; the post-resume check still catches a real + * failure and reports it loudly. + */ + private async assertRunResumable(runId: string | null | undefined, requestId: string): Promise { + if (!runId) return; + if (typeof this.automation?.resume !== 'function') return; + if (typeof this.automation?.hasSuspendedRun !== 'function') return; + let alive: boolean; + try { + alive = await this.automation.hasSuspendedRun(runId); + } catch (err: any) { + this.logger?.warn?.('[approvals] could not verify the flow run is resumable — proceeding', { + request: requestId, run: runId, error: err?.message ?? String(err), + }); + return; + } + if (!alive) { + throw new Error( + `RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} no longer exists ` + + `(it was cancelled, or it paused in a process that did not persist suspended runs). ` + + `Nothing was recorded. An administrator can recall the request to release the record.`, + ); + } + } + + /** + * Resume the run behind an outcome that has ALREADY been written down, and + * fail loudly when it cannot be (#4420). + * + * For the operations whose product is the resume — a finalised decision, a + * send-back, a resubmit. Their rows are durable by the time this runs, so a + * failure here cannot be undone; the one thing left worth doing is refusing + * to call it success. {@link assertRunResumable} is what keeps this rare: + * everything it catches never reaches a write. + * + * `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already + * advancing the run, so the outcome stands and only `resumed` is false. + * + * @param what - how the recorded outcome reads in the error, e.g. + * `"the approve decision"`. + */ + private async resumeRecordedOutcome( + runId: string, + requestId: string, + what: string, + signal: { output?: Record; branchLabel?: string }, + ): Promise<{ resumed: boolean; resumeError?: string }> { + try { + await this.serviceResume(runId, signal); + return { resumed: true }; + } catch (err: any) { + const reason = err?.message ?? String(err); + if (ApprovalService.resumeCodeOf(err) === 'RESUME_IN_PROGRESS') { + this.logger?.warn?.('[approvals] resume skipped — already in progress', { + request: requestId, run: runId, outcome: what, + }); + return { resumed: false, resumeError: reason }; + } + this.logger?.error?.('[approvals] resume failed — the run is stranded', { + request: requestId, run: runId, outcome: what, error: reason, + }); + throw new Error( + `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' ` + + `could not be resumed and is now stranded: ${reason}`, + ); + } } /** * Public contract entrypoint (ADR-0019). Records a decision on a node-driven * request via {@link ApprovalService.decideNode} and, when it finalizes, * resumes the owning flow run down the matching `approve` / `reject` edge. + * + * A finalising decision whose run cannot be resumed FAILS (#4420). The + * decision is already durable by then, so the failure cannot be rolled back + * — but it must not be reported as success either: this used to answer HTTP + * 200 with `resumed: true` while the flow stayed parked forever, which left + * the approver with no signal and the record mirroring a stage it never + * reached. `decideNode`'s pre-flight means the common case (the run died + * before the decision) never gets this far; what survives here is a genuine + * race, and it names the stranded run. */ async decide( requestId: string, @@ -1845,12 +2031,14 @@ export class ApprovalService implements IApprovalService { const result = await this.decideNode(requestId, input, context); let resumed = false; + let resumeError: string | undefined; if (result.finalized && result.runId && typeof this.automation?.resume === 'function') { const branchLabel = result.decision === 'approve' ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject; - try { - await this.serviceResume(result.runId, { + const outcome = await this.resumeRecordedOutcome( + result.runId, requestId, `the ${result.decision} decision`, + { branchLabel, // #3447 P2: accepted decision outputs ride the resume envelope and // land as `.` flow variables — a later approval node's @@ -1858,13 +2046,10 @@ export class ApprovalService implements IApprovalService { // Reserved keys are spread LAST so no output can shadow them (the // whitelist already rejects them; this is defense in depth). output: { ...(result.outputs ?? {}), decision: result.decision, requestId }, - }); - resumed = true; - } catch (err: any) { - this.logger?.warn?.('[approvals] resume after decision failed', { - request: requestId, run: result.runId, error: err?.message ?? String(err), - }); - } + }, + ); + resumed = outcome.resumed; + resumeError = outcome.resumeError; } return { @@ -1873,6 +2058,7 @@ export class ApprovalService implements IApprovalService { decision: result.decision, runId: result.runId, resumed, + ...(resumeError ? { resumeError } : {}), }; } @@ -1939,7 +2125,12 @@ export class ApprovalService implements IApprovalService { ); } + // A recall ABANDONS the request, so a run that cannot be resumed must not + // fail the call — the withdrawal and the record-lock release are the point, + // and they have already happened. It is still reported rather than + // swallowed: `resumed: false` plus a reason, logged at error (#4420). let resumed = false; + let resumeError: string | undefined; if (inReviseWindow) { // ADR-0044: the run is paused at the revise wait node, which has no // reject out-edge to resume down — terminally cancel it instead. @@ -1947,8 +2138,9 @@ export class ApprovalService implements IApprovalService { try { await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`); } catch (err: any) { - this.logger?.warn?.('[approvals] cancelRun after revise-window recall failed', { - request: requestId, run: runId, error: err?.message ?? String(err), + resumeError = err?.message ?? String(err); + this.logger?.error?.('[approvals] cancelRun after revise-window recall failed — the run may be stranded', { + request: requestId, run: runId, error: resumeError, }); } } @@ -1960,14 +2152,15 @@ export class ApprovalService implements IApprovalService { }); resumed = true; } catch (err: any) { - this.logger?.warn?.('[approvals] resume after recall failed', { - request: requestId, run: runId, error: err?.message ?? String(err), + resumeError = err?.message ?? String(err); + this.logger?.error?.('[approvals] resume after recall failed — the run may be stranded', { + request: requestId, run: runId, error: resumeError, }); } } const fresh = await this.readBackRequest(requestId, context); - return { request: fresh!, runId, resumed }; + return { request: fresh!, runId, resumed, ...(resumeError ? { resumeError } : {}) }; } // ── Send back for revision / resubmit (ADR-0044) ───────────── @@ -2003,6 +2196,10 @@ export class ApprovalService implements IApprovalService { const runId: string | null = raw.flow_run_id ?? null; await this.assertReviseEdge(raw, nodeId); + // A send-back exists to move the run to its revise wait point. If the run + // is gone there is nothing to send back TO, so refuse before writing — + // same reasoning as decideNode's pre-flight (#4420). + await this.assertRunResumable(runId, requestId); const now = this.clock.now().toISOString(); const maxRevisions = typeof (config as any).maxRevisions === 'number' ? (config as any).maxRevisions : 3; @@ -2042,18 +2239,17 @@ export class ApprovalService implements IApprovalService { ); } let resumed = false; + let resumeError: string | undefined; if (runId && typeof this.automation?.resume === 'function') { - try { - await this.serviceResume(runId, { + const outcome = await this.resumeRecordedOutcome( + runId, requestId, 'the auto-rejection', + { branchLabel: APPROVAL_BRANCH_LABELS.reject, output: { decision: 'reject', autoRejected: true, requestId }, - }); - resumed = true; - } catch (err: any) { - this.logger?.warn?.('[approvals] resume after auto-reject failed', { - request: requestId, run: runId, error: err?.message ?? String(err), - }); - } + }, + ); + resumed = outcome.resumed; + resumeError = outcome.resumeError; } if (raw.submitter_id) { await this.notify({ @@ -2069,7 +2265,7 @@ export class ApprovalService implements IApprovalService { }); } const fresh = await this.readBackRequest(requestId, context); - return { request: fresh!, runId, resumed, autoRejected: true }; + return { request: fresh!, runId, resumed, autoRejected: true, ...(resumeError ? { resumeError } : {}) }; } await this.engine.update('sys_approval_request', { @@ -2084,18 +2280,17 @@ export class ApprovalService implements IApprovalService { } let resumed = false; + let resumeError: string | undefined; if (runId && typeof this.automation?.resume === 'function') { - try { - await this.serviceResume(runId, { + const outcome = await this.resumeRecordedOutcome( + runId, requestId, 'the send-back', + { branchLabel: APPROVAL_BRANCH_LABELS.revise, output: { decision: 'revise', requestId }, - }); - resumed = true; - } catch (err: any) { - this.logger?.warn?.('[approvals] resume after send-back failed', { - request: requestId, run: runId, error: err?.message ?? String(err), - }); - } + }, + ); + resumed = outcome.resumed; + resumeError = outcome.resumeError; } if (raw.submitter_id) { @@ -2113,7 +2308,7 @@ export class ApprovalService implements IApprovalService { } const fresh = await this.readBackRequest(requestId, context); - return { request: fresh!, runId, resumed }; + return { request: fresh!, runId, resumed, ...(resumeError ? { resumeError } : {}) }; } /** @@ -2162,31 +2357,33 @@ export class ApprovalService implements IApprovalService { const runId: string | null = raw.flow_run_id ?? null; const now = this.clock.now().toISOString(); + // The next round only exists if the resume lands, so a run that is already + // gone fails the resubmit outright rather than recording a round that can + // never open (#4420). + await this.assertRunResumable(runId, requestId); + await this.engine.insert('sys_approval_action', { id: uid('aact'), request_id: requestId, organization_id: org, step_name: nodeId, step_index: 0, action: 'resubmit', actor_id: actorId, comment: input.comment ?? null, created_at: now, }, { context: SYSTEM_CTX }); - // The next round only exists if this resume lands — surface `resumed` - // honestly so a stuck run is visible instead of silently swallowed. let resumed = false; + let resumeError: string | undefined; if (runId && typeof this.automation?.resume === 'function') { - try { - await this.serviceResume(runId, { + const outcome = await this.resumeRecordedOutcome( + runId, requestId, 'the resubmit', + { branchLabel: APPROVAL_BRANCH_LABELS.resubmit, output: { resubmitted: true, requestId }, - }); - resumed = true; - } catch (err: any) { - this.logger?.warn?.('[approvals] resume after resubmit failed', { - request: requestId, run: runId, error: err?.message ?? String(err), - }); - } + }, + ); + resumed = outcome.resumed; + resumeError = outcome.resumeError; } const fresh = await this.readBackRequest(requestId, context); - return { request: fresh!, runId, resumed }; + return { request: fresh!, runId, resumed, ...(resumeError ? { resumeError } : {}) }; } /** @@ -2258,6 +2455,11 @@ export class ApprovalService implements IApprovalService { } const isOverride = this.isOverrideActor(context, raw.organization_id ?? null); const from = String(input.from ?? actorId).trim(); + // #4466 — same rule as `decideNode`: the marker records that the actor was + // admitted only by the privileged branch, holding no slot themselves. A + // reassign is the other action #3424 lets an admin take over a slate they + // are not on, so it carries the same fact. + const viaOverride = isOverride && !pending.includes(actorId); let next: string[]; if (pending.includes(from)) { // Normal hand-off: the actor holds the slot being moved (or is a @@ -2284,6 +2486,7 @@ export class ApprovalService implements IApprovalService { // comment (`""`) baked raw user ids into user-facing text. // `comment` is pure user input: absent unless the actor wrote one. actor_id: actorId, reassign_from: from, reassign_to: to, + via_override: viaOverride, comment: input.comment ?? null, created_at: now, }, { context: SYSTEM_CTX }); // per_group / quorum (#3266): carry the delegated slot's group membership to @@ -2670,6 +2873,145 @@ export class ApprovalService implements IApprovalService { * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run * release is never mistaken for a submitter's withdrawal. */ + /** + * Read-only inspection for the OTHER dead-run shape: a request that is + * already TERMINAL while its `flow_run_id` points at nothing (#4469). + * + * #4460 stopped new ones being produced; nothing found the ones already + * stuck. The failure mode (#4420) is a request row flipped to `approved` / + * `rejected` / `returned` whose owning run no longer exists — the decision + * landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the + * wiring hole and crossed a restart mid-approval can be carrying these rows. + * + * {@link releaseDeadRunRequests} cannot see them, for a reason worth naming: + * it scans `status: 'pending'`, and the very step that zombified the request + * is the one that took it OUT of `pending`. The act of breaking it removed it + * from the only sweeper's field of view — which is a large part of why this + * class of failure stayed silent. + * + * It also could not have answered the question even if it looked: its + * liveness oracle is `getRun`, which reads the execution LOG, and after a + * restart that returns `null` for a perfectly ALIVE suspended run. It treats + * `null` as alive (conservative, correct) — but that means it has no way to + * say "this run is really gone". + * + * So this uses BOTH oracles, and a row must fail both to be reported: + * + * - `hasSuspendedRun(runId) === false` — the suspension store itself says no + * live pause exists. It THROWS when the store cannot be read, and that + * case is SKIPPED, never counted as dead: an unreadable store means + * "unknown", and a storage outage must not be published as a lost run. + * - `getRun(runId) == null` — no terminal history row either (the `run_` + * prefixed rows in `sys_automation_run`). A run that merely finished is + * not stranded; a request whose run neither waits nor ever completed is. + * + * **Reports; never rewrites.** No status is changed and no run is cancelled. + * The decision genuinely happened — a human approved or rejected — and + * silently rolling it back would make the audit trail disagree with the + * facts. What an operator needs first is visibility: which requests are stuck + * at which step, and what the mirrored status field on the business record + * still says. Whether to re-run the downstream actions or re-open the + * approval is a judgement call this cannot make. + */ + async inspectStrandedRequests(options?: { limit?: number }): Promise<{ + scanned: number; + stranded: StrandedApprovalRequest[]; + /** Rows skipped because the suspension store could not be read — NOT healthy, just unknown. */ + undetermined: number; + }> { + const empty = { scanned: 0, stranded: [] as StrandedApprovalRequest[], undetermined: 0 }; + // Both oracles are required. Without `hasSuspendedRun` there is no way to + // tell a live cross-restart pause from a dead run, and reporting on + // `getRun` alone would name every healthy paused approval as stranded. + if (typeof this.automation?.hasSuspendedRun !== 'function') return empty; + if (typeof this.automation?.getRun !== 'function') return empty; + + const limit = options?.limit ?? 500; + let rows: any[] = []; + try { + rows = await this.engine.find('sys_approval_request', { + where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } }, limit, context: SYSTEM_CTX, + }) ?? []; + } catch (err: any) { + this.logger?.warn?.('[approvals] stranded-request scan failed to list requests', { + error: err?.message ?? String(err), + }); + return empty; + } + + const stranded: StrandedApprovalRequest[] = []; + let undetermined = 0; + for (const raw of rows) { + const runId = raw?.flow_run_id ? String(raw.flow_run_id) : ''; + if (!runId) continue; // not node-driven — no run was ever supposed to move + + let suspended: boolean; + try { + suspended = await this.automation.hasSuspendedRun!(runId); + } catch (err: any) { + // Store unreadable ⇒ existence unknown. Skipping is the only safe + // answer; counted so "0 stranded" can never be read as "all clear" + // when nothing could actually be checked. + undetermined++; + this.logger?.warn?.('[approvals] stranded-request scan could not read the suspension store', { + request: raw?.id, run: runId, error: err?.message ?? String(err), + }); + continue; + } + if (suspended) continue; // still parked — the run is alive and resumable + + let terminal: { status?: string } | null = null; + try { + terminal = await this.automation.getRun!(runId); + } catch (err: any) { + undetermined++; + this.logger?.warn?.('[approvals] stranded-request scan could not read the run history', { + request: raw?.id, run: runId, error: err?.message ?? String(err), + }); + continue; + } + if (terminal) continue; // the run ran to a terminal state — it is not dangling + + // Neither suspended nor ever finished: the run this decision was supposed + // to advance is genuinely gone. + const config = parseJson( + raw.node_config_json, { approvers: [], behavior: 'first_response' } as any, + ); + const mirrorField = config.approvalStatusField; + let mirroredStatus: string | undefined; + if (mirrorField) { + try { + const recs = await this.engine.find(raw.object_name, { + where: { id: raw.record_id }, limit: 1, context: SYSTEM_CTX, + }); + const rec: any = Array.isArray(recs) ? recs[0] : null; + if (rec) mirroredStatus = rec[mirrorField] ?? undefined; + } catch { /* display-only — a mirror read must never fail the scan */ } + } + stranded.push({ + requestId: String(raw.id), + status: raw.status, + runId, + flowName: typeof raw.process_name === 'string' ? raw.process_name.replace(/^flow:/, '') : undefined, + nodeId: raw.flow_node_id ?? raw.current_step ?? undefined, + objectName: raw.object_name, + recordId: raw.record_id, + organizationId: raw.organization_id ?? null, + completedAt: raw.completed_at ?? undefined, + mirrorField, + mirroredStatus, + }); + } + + if (stranded.length || undetermined) { + this.logger?.warn?.('[approvals] stranded terminal requests (decision recorded, flow run gone)', { + scanned: rows.length, stranded: stranded.length, undetermined, + requests: stranded.map(s => `${s.requestId}@${s.nodeId ?? '?'} → run ${s.runId}`), + }); + } + return { scanned: rows.length, stranded, undetermined }; + } + async releaseDeadRunRequests(): Promise<{ scanned: number; released: number }> { // No liveness oracle → no basis to declare anything dead. if (typeof this.automation?.getRun !== 'function') return { scanned: 0, released: 0 }; diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 0b4ded6574..d51cc8331c 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -185,6 +185,12 @@ export class ApprovalsServicePlugin implements Plugin { const results = await Promise.allSettled([ svc.runEscalations(), svc.releaseDeadRunRequests(), + // #4469 — the other half of the dead-run picture, and the one no + // sweeper could see: a request already TERMINAL whose run is gone. + // Read-only by design (it reports; it never rewrites a decision + // that really happened), so it rides the same clock purely to make + // the finding surface without an operator knowing to go looking. + svc.inspectStrandedRequests(), ]); for (const r of results) { if (r.status === 'rejected') { diff --git a/packages/plugins/plugin-approvals/src/index.ts b/packages/plugins/plugin-approvals/src/index.ts index b72a388b4f..bfc7d9a9ec 100644 --- a/packages/plugins/plugin-approvals/src/index.ts +++ b/packages/plugins/plugin-approvals/src/index.ts @@ -23,6 +23,8 @@ export { // #3447 P2 — expression approvers + empty-slate auto-approve outcome. type ApproverExpressionContext, type ApprovalNodeAutoOutcome, + // #4469 — the read-only stranded-request inspection's report shape. + type StrandedApprovalRequest, } from './approval-service.js'; export { ApprovalsServicePlugin, diff --git a/packages/plugins/plugin-approvals/src/stranded-request-inspection.test.ts b/packages/plugins/plugin-approvals/src/stranded-request-inspection.test.ts new file mode 100644 index 0000000000..4277ff717a --- /dev/null +++ b/packages/plugins/plugin-approvals/src/stranded-request-inspection.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Stranded terminal requests are found (#4469). + * + * #4420's failure shape: a request row flipped to `approved` (or `rejected`) + * while its `flow_run_id` points at a run that no longer exists — the decision + * landed, the flow never moved. #4460 stopped NEW ones being produced; the rows + * already stuck had no mechanism to find or release them. + * + * `releaseDeadRunRequests` cannot see them, and the reason is the interesting + * part: it scans `status: 'pending'`, and the very step that zombified the + * request is the one that took it OUT of `pending`. Breaking it removed it from + * the only sweeper's field of view. Its liveness oracle could not have answered + * anyway — `getRun` reads the execution LOG, which returns `null` for a + * perfectly alive suspended run after a restart. + * + * So the inspection uses BOTH oracles and reports only rows that fail both, + * skipping (never condemning) anything the stores could not answer for. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ApprovalService } from './approval-service.js'; + +interface FakeRow { [k: string]: any } + +function makeFakeEngine() { + const tables: Record = {}; + const ensure = (n: string) => (tables[n] ??= []); + function matches(row: FakeRow, filter: any): boolean { + if (!filter || typeof filter !== 'object') return true; + for (const [k, v] of Object.entries(filter)) { + const rv = row[k]; + if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(rv)) return false; + continue; + } + if (rv !== v) return false; + } + return true; + } + return { + _tables: tables, + async find(object: string, options?: any) { + const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); + return rows.slice(0, options?.limit ?? 1000); + }, + async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; }, + async update(object: string, idOrData: any, _opts?: any) { + const data = typeof idOrData === 'object' ? idOrData : _opts; + const id = typeof idOrData === 'object' ? idOrData.id : idOrData; + const table = ensure(object); + const i = table.findIndex(r => r.id === id); + if (i >= 0) table[i] = { ...table[i], ...data }; + return table[i]; + }, + async delete() { return {}; }, + registerHook() {}, unregisterHooksByPackage() { return 0; }, async fire() {}, + }; +} + +/** A terminal request row as the zombie leaves it: decision recorded, run gone. */ +function requestRow(over: Record = {}): FakeRow { + return { + id: 'areq_1', + process_name: 'flow:deal_approval', + object_name: 'opportunity', + record_id: 'opp1', + status: 'approved', + flow_run_id: 'run_1', + flow_node_id: 'co_sign', + organization_id: 't1', + completed_at: '2026-01-15T10:00:05.000Z', + node_config_json: JSON.stringify({ + approvers: [{ type: 'user', value: 'u9' }], + behavior: 'first_response', + approvalStatusField: 'approval_status', + }), + ...over, + }; +} + +/** An automation surface with both oracles, each independently steerable. */ +function automation(opts: { + suspended?: Record; + suspendedThrows?: boolean; + history?: Record; + historyThrows?: boolean; +} = {}) { + return { + async resume() { return { success: true }; }, + async hasSuspendedRun(runId: string) { + if (opts.suspendedThrows) throw new Error('suspended-run store unreadable'); + return opts.suspended?.[runId] ?? false; + }, + async getRun(runId: string) { + if (opts.historyThrows) throw new Error('run history unreadable'); + return opts.history?.[runId] ?? null; + }, + } as any; +} + +describe('stranded terminal request inspection (#4469)', () => { + let engine: ReturnType; + let svc: ApprovalService; + + beforeEach(() => { + engine = makeFakeEngine(); + svc = new ApprovalService({ engine: engine as any }); + }); + + it('the blind spot, stated: the existing pending-only sweep cannot see a terminal zombie', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation(automation()); + // `releaseDeadRunRequests` scans `status: 'pending'`; the zombie is + // `approved`, so its scan set is empty. + expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 }); + }); + + it('finds a terminal request whose run is neither suspended nor ever completed', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + engine._tables['opportunity'] = [{ id: 'opp1', approval_status: 'pending' }]; + svc.attachAutomation(automation()); + + const out = await svc.inspectStrandedRequests(); + expect(out.scanned).toBe(1); + expect(out.undetermined).toBe(0); + expect(out.stranded).toHaveLength(1); + expect(out.stranded[0]).toMatchObject({ + requestId: 'areq_1', + status: 'approved', + runId: 'run_1', + nodeId: 'co_sign', + flowName: 'deal_approval', + objectName: 'opportunity', + recordId: 'opp1', + }); + }); + + it('reports the stale mirrored status — what an operator actually sees on the record', async () => { + // The decision says `approved`; the business record still reads `pending` + // because the flow never resumed to move it. That disagreement is the + // human-facing symptom, so the report carries it. + engine._tables['sys_approval_request'] = [requestRow()]; + engine._tables['opportunity'] = [{ id: 'opp1', approval_status: 'pending' }]; + svc.attachAutomation(automation()); + + const [row] = (await svc.inspectStrandedRequests()).stranded; + expect(row.mirrorField).toBe('approval_status'); + expect(row.mirroredStatus).toBe('pending'); + }); + + it('does NOT report a request whose run is still suspended — that approval is healthy', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation(automation({ suspended: { run_1: true } })); + expect((await svc.inspectStrandedRequests()).stranded).toEqual([]); + }); + + it('does NOT report a request whose run ran to a terminal state — it finished, it is not dangling', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation(automation({ history: { run_1: { status: 'completed' } } })); + expect((await svc.inspectStrandedRequests()).stranded).toEqual([]); + }); + + it('SKIPS a row whose suspension store threw — an outage is unknown, not dead', async () => { + // The whole point of `hasSuspendedRun` rejecting rather than answering + // `false` (#4460): a storage blip must never be published as a lost run. + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation(automation({ suspendedThrows: true })); + + const out = await svc.inspectStrandedRequests(); + expect(out.stranded).toEqual([]); + // …and it is COUNTED, so "0 stranded" can never be read as "all clear" + // when nothing could actually be checked. + expect(out.undetermined).toBe(1); + }); + + it('SKIPS a row whose run history threw — same reasoning, second oracle', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation(automation({ historyThrows: true })); + + const out = await svc.inspectStrandedRequests(); + expect(out.stranded).toEqual([]); + expect(out.undetermined).toBe(1); + }); + + it('ignores a request with no `flow_run_id` — no run was ever supposed to move', async () => { + engine._tables['sys_approval_request'] = [requestRow({ flow_run_id: null })]; + svc.attachAutomation(automation()); + expect((await svc.inspectStrandedRequests()).stranded).toEqual([]); + }); + + it('ignores a `recalled` request — a recall abandons its run deliberately', async () => { + // `recall` explicitly tolerates a run it cannot resume; reporting those + // would bury the real findings under expected ones. + engine._tables['sys_approval_request'] = [requestRow({ status: 'recalled' })]; + svc.attachAutomation(automation()); + const out = await svc.inspectStrandedRequests(); + expect(out.scanned).toBe(0); + expect(out.stranded).toEqual([]); + }); + + it('covers `rejected` and `returned` too — both reach terminal only by resuming the run', async () => { + engine._tables['sys_approval_request'] = [ + requestRow({ id: 'areq_r', status: 'rejected', flow_run_id: 'run_r' }), + requestRow({ id: 'areq_v', status: 'returned', flow_run_id: 'run_v' }), + ]; + svc.attachAutomation(automation()); + const ids = (await svc.inspectStrandedRequests()).stranded.map(s => s.requestId); + expect(ids).toEqual(['areq_r', 'areq_v']); + }); + + it('NEVER rewrites a stranded row — the decision really happened', async () => { + // Auto-rolling back would make the audit trail disagree with the facts. + // The remedy (re-run downstream actions vs re-open the approval) is an + // operator judgement call, so the sweep only makes the rows visible. + engine._tables['sys_approval_request'] = [requestRow()]; + engine._tables['opportunity'] = [{ id: 'opp1', approval_status: 'pending' }]; + const before = JSON.stringify(engine._tables); + svc.attachAutomation(automation()); + + await svc.inspectStrandedRequests(); + + expect(JSON.stringify(engine._tables)).toBe(before); + expect(engine._tables['sys_approval_action'] ?? []).toHaveLength(0); + }); + + it('reports nothing when the engine offers no `hasSuspendedRun` — no oracle, no verdict', async () => { + // Without it there is no way to tell a live cross-restart pause from a dead + // run, and `getRun` alone would name every healthy paused approval stranded. + engine._tables['sys_approval_request'] = [requestRow()]; + svc.attachAutomation({ async resume() { return {}; }, async getRun() { return null; } } as any); + expect(await svc.inspectStrandedRequests()).toEqual({ scanned: 0, stranded: [], undetermined: 0 }); + }); + + it('reports nothing with no automation attached at all', async () => { + engine._tables['sys_approval_request'] = [requestRow()]; + expect(await svc.inspectStrandedRequests()).toEqual({ scanned: 0, stranded: [], undetermined: 0 }); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index cd7a01cd49..b0055d25bc 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -25,7 +25,7 @@ export const SysApprovalAction = ObjectSchema.create({ displayNameField: 'id', nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{action} · {step_name}', - highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'], + highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'via_override', 'created_at'], // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds // are OWNED by this row — and the storage service would otherwise authorize @@ -42,7 +42,7 @@ export const SysApprovalAction = ObjectSchema.create({ name: 'recent', label: 'Recent', data: { provider: 'object', object: 'sys_approval_action' }, - columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'], + columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'via_override', 'comment'], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, emptyState: { title: 'No approval actions yet', message: 'Actions are logged automatically when approvals progress.' }, @@ -62,7 +62,7 @@ export const SysApprovalAction = ObjectSchema.create({ name: 'all_actions', label: 'All', data: { provider: 'object', object: 'sys_approval_action' }, - columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'], + columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'via_override', 'comment'], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 100 }, }, @@ -119,6 +119,32 @@ export const SysApprovalAction = ObjectSchema.create({ comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }), + // #4466 — the one bit of "who really decided this" that was still dropped. + // A privileged admin may act on a request whose staffed approver slate they + // hold no slot in (the #3424 override path); before this column, that + // decision was byte-for-byte identical to the designated approver's own + // approval. A reader of the timeline saw `approve` by the admin and could + // not tell whether the admin WAS an approver or OVERRODE the ones who were, + // and the bypassed approver's later `409 INVALID_STATE` was the only trace + // — existing only if they happened to try. + // + // The platform KNOWS at decision time: it took the `isOverrideActor` branch + // to admit the call at all. This is dropped information, not unavailable + // information. + // + // Set on exactly the decisions that were admitted BY that branch — an admin + // who is also a genuine slot holder is approving normally and is recorded + // as such. Nullable and additive: rows written before this column exists + // carry `null`, which reads as "not recorded", never as "not an override". + via_override: Field.boolean({ + label: 'Via Admin Override', + required: false, + group: 'Action', + description: + 'True when the actor was admitted to this action only by the privileged-override path (#3424) — ' + + 'they held no slot in the request’s pending-approver slate.', + }), + // Structured hand-off parties for `action: 'reassign'` (#4365). Before // these existed the pair lived only inside a default free-text comment // (""), which no client could parse or render readably. diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 6350e68439..1263db7129 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -235,6 +235,10 @@ export const enObjects: NonNullable = { comment: { label: "Comment" }, + via_override: { + label: "Via Admin Override", + help: "True when the actor was admitted to this action only by the privileged-override path (#3424) — they held no slot in the request’s pending-approver slate." + }, reassign_from: { label: "Reassigned From", help: "User whose pending-approver slot was handed over (reassign actions only)" diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 807985fd52..4537c54bf6 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -235,6 +235,10 @@ export const esESObjects: NonNullable = { comment: { label: "Comentario" }, + via_override: { + label: "Mediante anulación de administrador", + help: "Verdadero cuando el actor fue admitido en esta acción únicamente por la vía de anulación privilegiada (#3424): no ocupaba ningún puesto en la lista de aprobadores pendientes de la solicitud." + }, reassign_from: { label: "Reasignado de", help: "Usuario cuyo turno de aprobación pendiente fue traspasado (solo acciones de reasignación)" diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 144266bccf..b82258128c 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -235,6 +235,10 @@ export const jaJPObjects: NonNullable = { comment: { label: "コメント" }, + via_override: { + label: "管理者オーバーライド経由", + help: "true の場合、実行者は特権オーバーライド経路(#3424)によってのみ許可されたことを示します — 当該リクエストの承認待ちリストには含まれていません。" + }, reassign_from: { label: "引き継ぎ元", help: "承認待ちスロットを引き渡したユーザー(引き継ぎ操作のみ)" diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index f48937c913..e847e086cd 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -235,6 +235,10 @@ export const zhCNObjects: NonNullable = { comment: { label: "评论" }, + via_override: { + label: "管理员越权操作", + help: "为真表示该操作者只是凭特权越权路径(#3424)被放行——他们并不在该请求的待审批人名单中。" + }, reassign_from: { label: "转出人", help: "被移交待审批槽位的用户(仅转签操作)" diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a76ff98e31..ce75bd826c 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -140,7 +140,7 @@ describe('SecurityPlugin', () => { // wildcard `current_user.organization_id` RLS policies. Otherwise it // strips them so single-tenant deployments aren't filtered to nothing. // ------------------------------------------------------------------------- - const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record; orgScoping?: boolean; findOneImpl?: (query: any) => any }) => { + const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record; orgScoping?: boolean; findOneImpl?: (query: any) => any; sharing?: any }) => { const fields: Record = {}; for (const f of overrides.objectFields ?? ['id', 'organization_id', 'owner_id', 'name']) { fields[f] = { name: f }; @@ -177,6 +177,10 @@ describe('SecurityPlugin', () => { // Sentinel object — SecurityPlugin only checks truthiness. services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; } + // [#4467] The optional plugin-sharing service. Absent by default, which is + // exactly the deployment shape every case above assumes; supply it to + // exercise the OWD/sharing half of the read scope. + if (overrides.sharing) services['sharing'] = overrides.sharing; const ctx: any = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, registerService: vi.fn(), @@ -1355,6 +1359,155 @@ describe('SecurityPlugin', () => { const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); expect(filter).toEqual(RLS_DENY_FILTER); }); + + // ----------------------------------------------------------------------- + // [#4467] The OWD / record-sharing half of the read scope. + // + // `getReadFilter` promises "the same filter the engine middleware AND-s + // into every find". That chain is TWO sibling middlewares — this plugin's + // RLS injection and plugin-sharing's owner/share visibility filter — and + // only the RLS half was ever computed here. The analytics raw-SQL path has + // no other source of scope, so `POST /analytics/query` ran with no owner + // predicate at all. Live repro on showcase before the fix, member holding + // shares on 2 of an admin's 5 private notes and no `viewAllRecords`: + // + // GET /data/showcase_private_note member → total 2 correct + // POST /analytics/query {measures:[count]} member → count 5 LEAK + // ... + dimensions:["title"] member → all 5 titles + // + // The dimension case is why this is a disclosure and not just a bad count: + // grouping returns the VALUES of a column the caller may not read. + // ----------------------------------------------------------------------- + describe('[#4467] OWD / sharing composition', () => { + /** A plugin-sharing double that scopes `task` to owner-or-shared. */ + const ownerOrShared = { + buildReadFilter: vi.fn(async (_object: string, ctx: any) => ({ + $or: [{ owner_id: ctx.userId }, { id: { $in: ['rec-1', 'rec-2'] } }], + })), + }; + + it('AND-composes the sharing predicate with the RLS filter', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [tenantPolicySet], + sharing: ownerOrShared, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + const filter = await plugin.getReadFilter('task', { + userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], + }); + + // Pre-fix this was `{ organization_id: 'org-1' }` alone — every row of + // the tenant, regardless of ownership. + expect(filter).toEqual({ + $and: [ + { organization_id: 'org-1' }, + { $or: [{ owner_id: 'u1' }, { id: { $in: ['rec-1', 'rec-2'] } }] }, + ], + }); + }); + + it('returns the sharing predicate alone when RLS contributes nothing', async () => { + // An owner-private object in a deployment with no tenant policy: the + // sharing half is then the ONLY thing standing between the caller and + // every row, so it must survive on its own rather than collapsing to + // `undefined` with the empty RLS half. + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [{ + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true } }, + } as any], + sharing: ownerOrShared, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + const filter = await plugin.getReadFilter('task', { + userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], + }); + + expect(filter).toEqual({ $or: [{ owner_id: 'u1' }, { id: { $in: ['rec-1', 'rec-2'] } }] }); + }); + + it('passes the ADR-0057 D1 read DEPTH the middleware would have stashed', async () => { + // plugin-sharing widens its owner-match from `__readScope`, which the + // engine middleware writes onto the context before the sharing + // middleware runs. No middleware runs on this path, so getReadFilter + // must compute it — otherwise a caller granted `org` read depth is + // silently narrowed to `own` here while `/data` shows them everything. + const capture = { buildReadFilter: vi.fn(async () => null) }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [{ + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, readScope: 'unit' } }, + } as any], + sharing: capture, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + await plugin.getReadFilter('task', { + userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], + }); + + expect(capture.buildReadFilter).toHaveBeenCalledWith( + 'task', + expect.objectContaining({ __readScope: 'unit', userId: 'u1' }), + ); + }); + + it('fail-closed: a sharing-resolution throw denies rather than under-scoping', async () => { + // Dropping this predicate is precisely the leak, so an unresolvable + // sharing layer must deny — never fall through to the RLS half alone. + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [tenantPolicySet], + sharing: { buildReadFilter: async () => { throw new Error('share store unavailable'); } }, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + const filter = await plugin.getReadFilter('task', { + userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], + }); + + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('a system context still bypasses both halves', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const sharing = { buildReadFilter: vi.fn(async () => ({ owner_id: 'u1' })) }; + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], sharing }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + const filter = await plugin.getReadFilter('task', { isSystem: true, userId: 'u1', tenantId: 'org-1' }); + + expect(filter).toBeUndefined(); + expect(sharing.buildReadFilter).not.toHaveBeenCalled(); + }); + + it('a deployment without plugin-sharing is unaffected', async () => { + // The service is optional; its absence must not change the RLS answer + // (and must not throw on the lookup). + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + + const filter = await plugin.getReadFilter('task', { + userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], + }); + + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0d37fdf3e4..07e6fdd7ce 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2254,6 +2254,68 @@ export class SecurityPlugin implements Plugin { ); } + /** + * [#4467] The OWD / record-sharing half of the read scope — plugin-sharing's + * `buildReadFilter` for `object` under `context`, resolved through the + * late-bound `sharing` service. + * + * `getReadFilter` promises "the same filter the engine middleware AND-s into + * every find". That chain is TWO sibling middlewares: this plugin's RLS + * injection and plugin-sharing's owner/share visibility filter. Only the RLS + * half was ever computed here, so the analytics raw-SQL path — which bypasses + * the engine and has no other source of scope — ran with no owner predicate at + * all: a member could `COUNT(*)` an owner-private object they hold no share on, + * and `GROUP BY title` read the values themselves out of rows `/data` correctly + * refused them. + * + * The DEPTH the owner-match widens to (ADR-0057 D1) is stashed on the context + * by the middleware as `__readScope` before plugin-sharing reads it; no + * middleware runs on this path, so it is computed here from the SAME evaluator + * call the middleware makes. Without it a caller granted `unit`/`org` read + * depth would be scoped to `own` — safe, but a silent disagreement between + * `/data` and `/analytics` in the other direction. + * + * Returns `null` when the sharing layer imposes nothing (no plugin-sharing, a + * public object, an object with no owner field, a bypass object). THROWS on a + * resolution failure so the caller can fail closed — a dropped sharing + * predicate is exactly the leak this fixes. + */ + private async resolveSharingReadFilter( + object: string, + context: any, + ): Promise | null> { + const sharing = this.resolveKernelService?.('sharing') as + | { buildReadFilter?: (o: string, c: any) => Promise } + | undefined; + if (!sharing || typeof sharing.buildReadFilter !== 'function') return null; + // Mirror the middleware's ADR-0057 D1 depth stash. `getEffectiveScope` + // needs the resolved sets and the object's posture — the same two inputs + // the middleware feeds it — so the owner-match widens identically here. + let readScope: string | undefined; + try { + const permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length > 0) { + const meta = await this.getObjectSecurityMeta(object); + readScope = this.permissionEvaluator.getEffectiveScope( + 'read', + object, + permissionSets, + { isPrivate: meta.isPrivate }, + ); + } + } catch { + // Depth is a WIDENING input: failing to resolve it leaves the owner-match + // at its narrowest ('own'), which is the safe direction. The sharing call + // below still runs — and its own failure still denies. + readScope = undefined; + } + const filter = await sharing.buildReadFilter(object, { + ...context, + ...(readScope ? { __readScope: readScope } : {}), + }); + return (filter ?? null) as Record | null; + } + async getReadFilter( object: string, context?: any, @@ -2262,11 +2324,29 @@ export class SecurityPlugin implements Plugin { if (context?.isSystem) return undefined; const positions = context?.positions ?? []; const explicit = context?.permissions ?? []; - // Unauthenticated + position-less + permission-less → no scope (the auth + // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller, + // ahead of the RLS branches below, because it is a SEPARATE middleware in + // the chain this method mirrors: none of the RLS stand-downs below is a + // reason to drop it. A resolution failure denies outright — running the + // analytics raw-SQL path with a dropped owner predicate is the leak. + let sharingFilter: Record | null; + try { + sharingFilter = await this.resolveSharingReadFilter(object, context); + } catch (e) { + this.logger.error?.( + `[security] getReadFilter could not resolve the sharing (OWD) read scope for object ` + + `'${object}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed, #4467)`, + e instanceof Error ? e : new Error(String(e)), + ); + return { ...RLS_DENY_FILTER }; + } + // Unauthenticated + position-less + permission-less → no RLS scope (the auth // layer, not RLS, gates anonymous access; the analytics REST endpoint - // already 401s without a token). Mirrors the middleware's early `return next()`. + // already 401s without a token). Mirrors the middleware's early `return next()` + // — which is the RLS middleware's early exit only, so the sharing predicate + // resolved above still applies. if (positions.length === 0 && explicit.length === 0 && !context?.userId) { - return undefined; + return sharingFilter ?? undefined; } // [#2852] D10 delegator intersection is NOT implemented on this path. // The engine middleware (find/count/aggregate) intersects an on-behalf-of @@ -2292,7 +2372,10 @@ export class SecurityPlugin implements Plugin { try { const permissionSets = await this.resolvePermissionSetsForContext(context); const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); - return filter ?? undefined; + // [#4467] RLS AND sharing — the same AND-composition the two middlewares + // achieve by both writing into `ast.where`. Either half may be absent; + // `andComposeLayers` returns the other, or null when neither constrains. + return andComposeLayers(filter, sharingFilter) ?? undefined; } catch (e) { // Fail CLOSED — a resolution failure must deny (zero rows), never expose // every tenant's data through the raw-SQL analytics path. diff --git a/packages/plugins/plugin-sharing/src/boot-backfill.test.ts b/packages/plugins/plugin-sharing/src/boot-backfill.test.ts index 0fea98f84e..7394cb6e35 100644 --- a/packages/plugins/plugin-sharing/src/boot-backfill.test.ts +++ b/packages/plugins/plugin-sharing/src/boot-backfill.test.ts @@ -58,6 +58,13 @@ function makeEngine() { return t[i]; }, async delete(o: string, opts?: any) { + // Mirror `ObjectQLEngine.delete`'s dispatch guard (#4434) — see the same + // note in sharing-rule.test.ts. A fake looser than the contract it + // stands in for is how a green suite ships a dead route. + const whereId = opts?.where && typeof opts.where === 'object' ? (opts.where as any).id : undefined; + const t0 = typeof whereId; + const scalarId = whereId != null && (t0 === 'string' || t0 === 'number' || t0 === 'bigint'); + if (!scalarId && !opts?.multi) throw new Error('Delete requires an ID or options.multi=true'); const t = ensure(o); const where = opts?.where ?? {}; for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1); return { ok: true }; @@ -127,6 +134,94 @@ describe('backfillRuleGrants (#2926 ③ — seed rows materialize at boot)', () }); }); +/** + * objectstack#4433 (restart half) — "not at boot". + * + * The boot pass was handed `listRules({ activeOnly: true })`, so a deactivated + * rule was never evaluated and the grants it had materialised survived every + * restart: the rule read `active: false` while the orphaned `source: 'rule'` + * row kept answering. The pass is the last line of defence for withdrawal, so + * it has to walk EVERY rule — `evaluateRule` purges the ones it finds inactive. + */ +describe('boot rule backfill withdraws deactivated rules (#4433)', () => { + let engine: ReturnType; + let sharing: SharingService; + let rules: SharingRuleService; + + beforeEach(async () => { + engine = makeEngine(); + sharing = new SharingService({ engine: engine as any }); + rules = new SharingRuleService({ engine: engine as any, sharing }); + engine._tables.showcase_private_note = [ + { id: 'note_n', title: 'rc1 rule target', owner_id: 'admin' }, + ]; + await rules.defineRule({ + name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note', + criteria: { title: 'rc1 rule target' }, + recipientType: 'user', recipientId: 'member_b', accessLevel: 'read', + }, SYS); + // Boot 1 materialises the grant — the issue's step 3 baseline. + await backfillRuleGrants(rules, await rules.listRules({}, SYS)); + expect(engine._tables.sys_record_share ?? []).toHaveLength(1); + }); + + it('revokes a deactivated rule\'s grants on the next boot', async () => { + // Admin switches the rule off. Nothing else touches the record. + await rules.defineRule({ + name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note', + criteria: { title: 'rc1 rule target' }, + recipientType: 'user', recipientId: 'member_b', accessLevel: 'read', active: false, + }, SYS); + + // Restart: the pass must see the inactive rule to be able to purge it. + await backfillRuleGrants(rules, await rules.listRules({}, SYS)); + + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + }); + + it('an activeOnly rule list can never withdraw — the #4433 boot repro', async () => { + await rules.defineRule({ + name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note', + criteria: { title: 'rc1 rule target' }, + recipientType: 'user', recipientId: 'member_b', accessLevel: 'read', active: false, + }, SYS); + + // The old call site, pinned as the defect it was: an inactive rule is + // absent from the list, so the pass has nothing to purge with. + const activeOnly = await rules.listRules({ activeOnly: true }, SYS); + expect(activeOnly).toHaveLength(0); + await backfillRuleGrants(rules, activeOnly); + expect(engine._tables.sys_record_share ?? []).toHaveLength(1); // still granted + + // Reconciling every rule is what repairs it. + await backfillRuleGrants(rules, await rules.listRules({}, SYS)); + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + }); + + it('still materialises active rules (the #2926 behaviour is untouched)', async () => { + engine._tables.showcase_private_note.push({ id: 'note_2', title: 'rc1 rule target', owner_id: 'admin' }); + await backfillRuleGrants(rules, await rules.listRules({}, SYS)); + expect((engine._tables.sys_record_share ?? []).map((s) => s.record_id).sort()).toEqual(['note_2', 'note_n']); + }); + + it('sweeps grants whose rule row vanished before the restart', async () => { + // A rule removed by a path that never reached deleteRule (data-API delete + // with the hook unbound, a migration, a crash mid-delete). + engine._tables.sys_sharing_rule = []; + await backfillRuleGrants(rules, await rules.listRules({}, SYS)); + expect(engine._tables.sys_record_share ?? []).toHaveLength(1); // unreachable by rule iteration + + expect(await rules.sweepOrphanedRuleGrants()).toBe(1); + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + }); + + it('the sweep is idempotent across repeated boots', async () => { + expect(await rules.sweepOrphanedRuleGrants()).toBe(0); + expect(await rules.sweepOrphanedRuleGrants()).toBe(0); + expect(engine._tables.sys_record_share ?? []).toHaveLength(1); + }); +}); + describe("backfillRetiredAccessLevels (#3865 — stored 'full' normalises to 'edit')", () => { let engine: ReturnType; diff --git a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts index 90bfd0af2e..85d42d42b5 100644 --- a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts +++ b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts @@ -171,11 +171,16 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () => listRules: vi.fn(async () => []), evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 2, expandedUsers: 1, grantsCreated: 2, grantsUpdated: 0, grantsRevoked: 0 })), revokeRuleGrants: vi.fn(async () => 2), + sweepOrphanedRuleGrants: vi.fn(async () => 0), }; (plugin as any).ruleService = ruleService; const ctx = makeCtx(); logger = ctx.logger; (plugin as any).bindRuleRebindTriggers(engine, ctx); + // [#4433] Boot is over — `kernel:bootstrapped` has run its backfill, so + // runtime rule writes own reconciliation from here. Before this point the + // trigger defers to that pass (see the boot-phase describe below). + (plugin as any).ruleGrantsBootReconciled = true; }); it('backfills existing records when a rule is created', async () => { @@ -196,12 +201,26 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () => expect(ruleService.evaluateRule).not.toHaveBeenCalled(); }); - it('skips system-context writes — boot backfill owns those', async () => { - await engine.fire('afterInsert', 'sys_sharing_rule', { - result: { id: 'r1' }, + /** + * objectstack#4433 — this is the defect, and it hid behind the test that + * used to live here ("skips system-context writes"). + * + * `SharingRuleService.defineRule` — the ONLY implementation behind + * `POST /sharing/rules`, the documented way to deactivate a rule — writes + * `sys_sharing_rule` with SYSTEM_CTX unconditionally, because it has to + * reach a platform table the sharing middleware otherwise gates. So the old + * `session.isSystem` skip did not filter out "boot seeding"; it filtered out + * every REST authoring write there is. The old test passed a mocked + * `session: { isSystem: true }` that the real REST path never sends, and + * asserted the reconcile did NOT happen — pinning the bug as the contract. + */ + it('reconciles a SYSTEM_CTX authoring write once boot is done (#4433)', async () => { + // Exactly what `POST /sharing/rules` with `active: false` produces. + await engine.fire('afterUpdate', 'sys_sharing_rule', { + result: { id: 'r1', active: false }, session: { isSystem: true }, }); - expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); }); it('never fails the authoring write when reconciliation throws', async () => { @@ -228,3 +247,71 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () => expect(order).toEqual(['rebind', 'reconcile']); }); }); + +/** + * [#4433] Boot phase — the predicate that replaced the `isSystem` skip. + * + * The skip exists to avoid duplicating work: declared-rule seeding and package + * bootstrap write `sys_sharing_rule` before `kernel:bootstrapped`, and that + * pass reconciles every rule anyway. That is a statement about WHEN a write + * happens, not about WHO made it — so it is gated on boot phase, which is true + * for exactly the writes the backfill covers and false for every runtime one. + */ +describe('SharingServicePlugin defers reconciliation to the boot backfill (#4433)', () => { + let engine: ReturnType; + let plugin: SharingServicePlugin; + let ruleService: AnyRecord; + + beforeEach(() => { + engine = makeEngine(); + plugin = new SharingServicePlugin(); + ruleService = { + listRules: vi.fn(async () => []), + evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: 0 })), + revokeRuleGrants: vi.fn(async () => 0), + sweepOrphanedRuleGrants: vi.fn(async () => 0), + }; + (plugin as any).ruleService = ruleService; + (plugin as any).bindRuleRebindTriggers(engine, makeCtx()); + // Boot still in flight — `ruleGrantsBootReconciled` defaults to false. + }); + + it('skips the per-write reconcile while boot is still in flight', async () => { + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'seeded' } }); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + }); + + it('still rebinds the lifecycle hooks during boot', async () => { + // Deferring the reconcile must not defer the binding — a rule seeded at + // boot has to be enforceable for records written straight afterwards. + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'seeded' } }); + expect(ruleService.listRules).toHaveBeenCalled(); + }); + + it('reconciles every write once the backfill has run — user session', async () => { + (plugin as any).ruleGrantsBootReconciled = true; + await engine.fire('afterUpdate', 'sys_sharing_rule', { + result: { id: 'r1', active: false }, + session: { userId: 'admin' }, + }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('reconciles every write once the backfill has run — system session', async () => { + (plugin as any).ruleGrantsBootReconciled = true; + await engine.fire('afterUpdate', 'sys_sharing_rule', { + result: { id: 'r1', active: false }, + session: { isSystem: true }, + }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('purges on a post-boot delete regardless of session kind', async () => { + (plugin as any).ruleGrantsBootReconciled = true; + await engine.fire('afterDelete', 'sys_sharing_rule', { + input: { id: 'r1' }, + session: { isSystem: true }, + }); + expect(ruleService.revokeRuleGrants).toHaveBeenCalledWith('r1'); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 6ea9378a13..3094ec5719 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -42,10 +42,18 @@ export interface SharingPluginOptions { * but seed rows are written with `isSystem` (which the hooks deliberately * skip — see rule-hooks.ts), so a fresh deploy's seed data carried no * `sys_record_share` rows until each record was touched at runtime. - * Reconcile every active rule once per boot: `evaluateRule` is idempotent + * Reconcile every rule once per boot: `evaluateRule` is idempotent * (diff-based grant/update/revoke), so repeated boots are no-ops. * Best-effort per rule — one broken rule must not block startup or its * siblings. Returns the number of rules successfully reconciled. + * + * [#4433] Callers must pass EVERY rule, not just the active ones. This pass is + * the last line of defence for withdrawal: `evaluateRule` purges the grants of + * a rule it finds inactive, so an inactive rule in this list is what turns a + * restart into a repair. Handed only active rules — as it was — the pass could + * physically never revoke anything a deactivated rule had left behind, which + * is why the #4433 repro survived a full restart with the rule reading + * `active: false` and the grant still answering. */ export async function backfillRuleGrants( ruleService: SharingRuleService, @@ -223,6 +231,21 @@ export class SharingServicePlugin implements Plugin { /** Resolved once in `kernel:ready`; reused by the `kernel:bootstrapped` backfills. */ private engine?: SharingEngine; + /** + * [#4433] Has the `kernel:bootstrapped` rule-grant backfill finished? + * + * This is the real question the rule-write trigger needs to answer before it + * decides to skip a reconcile — "is the boot pass going to cover this write + * anyway?" It used to ask `session.isSystem` instead, which is a different + * question with a very different answer: `SharingRuleService.defineRule` + * writes `sys_sharing_rule` with SYSTEM_CTX **always** (it must, to reach a + * platform table the sharing middleware otherwise gates), so every runtime + * authoring write — including `POST /sharing/rules` with `active: false` — + * looked exactly like boot seeding and was skipped. That is the whole of + * #4433's first half: deactivation returned 200, and nothing reconciled. + */ + private ruleGrantsBootReconciled = false; + constructor(options: SharingPluginOptions = {}) { this.options = options; } @@ -265,8 +288,19 @@ export class SharingServicePlugin implements Plugin { * `evaluateRule` the REST `/sharing/rules/:id/evaluate` endpoint runs, which * is diff-based and purges when the rule is inactive. Deletes can't go * through it (the row is gone, `RULE_NOT_FOUND`), so they purge directly. - * System-context writes are skipped: seeding and package bootstrap write - * with `isSystem`, and `kernel:bootstrapped` already backfills those. + * + * [#4433] The reconcile is skipped only until the `kernel:bootstrapped` + * backfill has run — NOT for every `isSystem` write, as it was. #3821 built + * this seam and then gated it on the one predicate that switches it off + * everywhere it mattered: `defineRule` — the sole implementation behind + * `POST /sharing/rules`, the documented way to deactivate a rule — writes + * with SYSTEM_CTX unconditionally, so the `isSystem` skip caught 100% of + * REST authoring. The withdrawal path was present, tested (against a mocked + * `session` the real path never sends) and unreachable in production: an + * admin saving `active: false` got a 200 and no reconcile, and because boot + * backfill then only walked ACTIVE rules, the orphaned grant outlived every + * restart. Boot phase is the honest predicate — before it, the backfill owes + * this table a pass; after it, nothing else will do the work. */ private bindRuleRebindTriggers(engine: any, ctx: PluginContext): void { const scheduleRebind = (): Promise => { @@ -310,9 +344,14 @@ export class SharingServicePlugin implements Plugin { error: err?.message, }); } - // Seeding / package bootstrap write with `isSystem`; `kernel:bootstrapped` - // backfills those, so reconciling here would only duplicate that work. - if (hookCtx?.session?.isSystem) return; + // [#4433] Skip only while the boot backfill still owes this table a + // pass. Declared-rule seeding and package bootstrap run before + // `kernel:bootstrapped`, and that pass reconciles every rule, so + // reconciling here would duplicate it. Once it has run, every write + // reconciles — regardless of `isSystem`, which cannot distinguish boot + // seeding from an admin's `POST /sharing/rules` (both arrive as + // SYSTEM_CTX from `defineRule`). + if (!this.ruleGrantsBootReconciled) return; const data = hookCtx?.result ?? hookCtx?.input?.data ?? {}; const ruleId = String(data?.id ?? hookCtx?.input?.id ?? ''); if (!ruleId) return; @@ -595,11 +634,26 @@ export class SharingServicePlugin implements Plugin { if (!this.ruleService) return; try { - const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any); + // [#4433] EVERY rule, not `activeOnly` — a deactivated rule's grants + // are withdrawn by reconciling it, so excluding inactive rules made + // the boot pass structurally incapable of repairing them. + const rules = await this.ruleService.listRules({}, { isSystem: true } as any); await backfillRuleGrants(this.ruleService, rules, ctx.logger as any); } catch (err: any) { ctx.logger.warn('SharingServicePlugin: boot rule backfill (kernel:bootstrapped) failed', { error: err?.message }); } + // [#4433] Grants whose rule row is gone entirely are unreachable by + // reconciling rules — there is no rule left to iterate. Sweep them + // separately so "the rule is gone" and "its access is gone" mean the + // same thing after a restart, whichever path removed the rule. + try { + await this.ruleService.sweepOrphanedRuleGrants(); + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: orphaned rule-grant sweep (kernel:bootstrapped) failed', { error: err?.message }); + } + // Withdrawal is now complete for this boot; runtime rule writes own it + // from here (see bindRuleRebindTriggers). + this.ruleGrantsBootReconciled = true; }); } } diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 59fa8066b6..03b62031a3 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -246,10 +246,25 @@ export class SharingRuleService implements ISharingRuleService { const row = await this.getRule(idOrName, context); if (!row) return; // Drop materialised grants first so we don't orphan them. - await this.engine.delete('sys_record_share', { - where: { source: 'rule', source_id: row.id }, - context: SYSTEM_CTX, - } as any); + // + // [#4434] This used to be a predicate-shaped `engine.delete` on + // `sys_record_share` (`where: { source, source_id }`) with neither a + // scalar id nor `multi: true` — the one shape the engine's dispatch + // refuses, so EVERY `DELETE /sharing/rules/:idOrName` threw + // 'Delete requires an ID or options.multi=true' and answered 500 before + // it ever reached the rule row. Both address forms died on it, which left + // an over-granting rule unrecoverable from the API surface once #4433 had + // also closed the deactivation path. + // + // The fix routes through {@link purgeRuleGrants} rather than adding + // `multi: true` to the bulk call: it is the same revoke path every other + // withdrawal already uses (`evaluateRule` on an inactive rule, + // `revokeRuleGrants` after a data-API delete), so a rule's grants are + // retired exactly one way — through `SharingService.revoke`, one row at a + // time by scalar id — instead of two divergent ones. Adding `multi` here + // would have fixed the 500 while keeping delete as the only withdrawal + // that bypasses the sharing service (AGENTS.md PD #5). + await this.purgeRuleGrants(row.id); await this.engine.delete('sys_sharing_rule', { where: { id: row.id }, context: SYSTEM_CTX, @@ -281,16 +296,82 @@ export class SharingRuleService implements ISharingRuleService { return this.purgeRuleGrants(ruleId); } + /** + * [#4433] Revoke every `source: 'rule'` grant whose `source_id` no longer + * resolves to a rule row at all, and report how many went. + * + * Reconciling the rules themselves — which the boot backfill now does for + * inactive rules too — can only reach grants some surviving rule still + * claims. A grant whose rule row is GONE is unreachable that way: there is + * nothing left to iterate. Those orphans are exactly the rows #4433 found + * still answering after a restart, and they arise from every path that + * removes a rule without going through {@link deleteRule} — a data-API + * delete while the reconcile hook was unbound, a row dropped by a migration + * or by hand, a crash between the two writes in `deleteRule`. Sweeping at + * boot is what makes "the rule is gone" and "its access is gone" the same + * statement no matter which path removed it. + * + * Reads the rule ids first and diffs in memory: the grant table is the big + * one, and a per-grant existence probe would be one query per row. + */ + async sweepOrphanedRuleGrants(): Promise { + const ruleRows = await this.engine.find('sys_sharing_rule', { + fields: ['id'], + limit: 100000, + context: SYSTEM_CTX, + }); + const live = new Set(); + for (const r of (ruleRows ?? [])) live.add(String((r as any).id)); + + const grants = await this.engine.find('sys_record_share', { + where: { source: 'rule' }, + fields: ['id', 'source_id'], + limit: 100000, + context: SYSTEM_CTX, + }); + let revoked = 0; + for (const g of (grants ?? [])) { + const sourceId = (g as any).source_id; + // A `source: 'rule'` row with no `source_id` names no rule that could + // ever re-grant it — equally unreachable, equally void. + if (sourceId != null && live.has(String(sourceId))) continue; + await this.sharing.revoke(String((g as any).id), SYSTEM_CTX as any); + revoked += 1; + } + if (revoked > 0) { + this.logger?.warn?.( + '[sharing-rule] revoked rule grants whose rule row no longer exists', + { grants: revoked }, + ); + } + return revoked; + } + + /** + * Reconcile every rule on `object` against ONE record — the per-record pass + * the afterInsert/afterUpdate hooks run. + * + * [#4433] Deliberately lists ALL rules, not just active ones. Filtering to + * `activeOnly` here meant a deactivated rule was simply absent from the + * loop, so the grants it had already materialised were never even looked + * at: touching the record — the very event that created the grant — walked + * straight past it. An inactive rule is not "no rule", it is a rule whose + * desired grant set is EMPTY, and only by reconciling it can the stale rows + * be revoked. `match: false` for an inactive rule sends `reconcileForRecord` + * down its existing revoke-the-remainder branch, so nothing new is needed to + * withdraw them. + */ async evaluateAllForRecord( object: string, recordId: string, context: SharingExecutionContext, ): Promise { - const rules = await this.listRules({ object, activeOnly: true }, context); + const rules = await this.listRules({ object }, context); if (rules.length === 0) return []; const results: SharingRuleEvaluationResult[] = []; for (const rule of rules) { - const match = await this.recordMatches(rule, recordId); + // An inactive rule desires nothing; skip the criteria query entirely. + const match = rule.active ? await this.recordMatches(rule, recordId) : false; const users = match ? await this.expandRecipient(rule) : []; results.push(await this.reconcileForRecord(rule, recordId, match, users)); } diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index bc428fb478..292aef923b 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -59,6 +59,7 @@ function makeEngine() { return t[i]; }, async delete(o: string, opts?: any) { + assertDeletable(opts); const t = ensure(o); const where = opts?.where ?? {}; for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1); return { ok: true }; @@ -66,6 +67,24 @@ function makeEngine() { }; } +/** + * Mirror `ObjectQLEngine.delete`'s dispatch guard (objectstack#4434). + * + * The real engine routes a delete by SCALAR `where.id` to `driver.delete` and + * anything else to `driver.deleteMany` — but only when `options.multi` is set; + * otherwise it throws `'Delete requires an ID or options.multi=true'`. The fake + * used to accept any `where`, so `deleteRule`'s predicate-shaped purge of + * `sys_record_share` passed here while the running server answered 500 to every + * `DELETE /sharing/rules/:idOrName`. A fake looser than the contract it stands + * in for is how a green suite ships a dead route. + */ +function assertDeletable(opts?: any): void { + const whereId = opts?.where && typeof opts.where === 'object' ? (opts.where as any).id : undefined; + const t = typeof whereId; + const scalarId = whereId != null && (t === 'string' || t === 'number' || t === 'bigint'); + if (!scalarId && !opts?.multi) throw new Error('Delete requires an ID or options.multi=true'); +} + describe('TeamGraphService (flat — better-auth sys_team)', () => { let engine: ReturnType; beforeEach(() => { @@ -321,6 +340,146 @@ describe('SharingRuleService', () => { expect(engine._tables.sys_record_share).toHaveLength(0); }); + /** + * objectstack#4434 — `DELETE /sharing/rules/:idOrName` answered 500 for BOTH + * address forms. `deleteRule` purged `sys_record_share` with a + * predicate-shaped `engine.delete` carrying neither a scalar id nor + * `multi: true`, the one shape the engine's dispatch refuses, so it threw + * before ever reaching the rule row. With #4433 also closing the + * deactivation path, an over-granting rule had no withdrawal path left at + * all — the reason both were RC-exit blockers. + */ + it('deleteRule issues only engine-legal deletes — by NAME (#4434)', async () => { + await rules.defineRule({ + name: 'rc1_rule_livetest', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule('rc1_rule_livetest', SYS); + expect(engine._tables.sys_record_share.length).toBeGreaterThan(0); + + // The repro addressed the rule by name; the throw was unconditional. + await expect(rules.deleteRule('rc1_rule_livetest', SYS)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule).toHaveLength(0); + expect(engine._tables.sys_record_share).toHaveLength(0); + }); + + it('deleteRule issues only engine-legal deletes — by ID (#4434)', async () => { + const r = await rules.defineRule({ + name: 'rc1_rule_livetest', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule(r.id, SYS); + await expect(rules.deleteRule(r.id, SYS)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule).toHaveLength(0); + expect(engine._tables.sys_record_share).toHaveLength(0); + }); + + it('deleteRule withdraws grants through the sharing service, not a bulk delete (#4434)', async () => { + const r = await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule(r.id, SYS); + const granted = engine._tables.sys_record_share.length; + expect(granted).toBeGreaterThan(0); + + // Every grant retires down the same path as every other withdrawal + // (evaluateRule-on-inactive, revokeRuleGrants) — one revoke per row — + // rather than a second, divergent bulk-delete path. + const revoke = vi.spyOn(sharing, 'revoke'); + await rules.deleteRule(r.id, SYS); + expect(revoke).toHaveBeenCalledTimes(granted); + revoke.mockRestore(); + }); + + /** + * objectstack#4433 (record-touch half) — `evaluateAllForRecord` listed only + * ACTIVE rules, so a deactivated rule was absent from the loop entirely and + * the grants it had materialised were never even examined. Touching the + * record — the very event that created the grant — walked past it, which is + * step 6 of the issue's repro. + */ + it('touching a record withdraws a deactivated rule\'s grants (#4433)', async () => { + const r = await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule(r.id, SYS); + const before = engine._tables.sys_record_share.filter(s => s.record_id === 'opp1'); + expect(before.length).toBeGreaterThan(0); + + // Admin switches the rule OFF (no explicit evaluate), then the record is + // touched — the afterUpdate hook's call. + await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', active: false, + }, SYS); + const res = await rules.evaluateAllForRecord('opportunity', 'opp1', SYS); + + expect(res[0].grantsRevoked).toBe(before.length); + expect(engine._tables.sys_record_share.filter(s => s.record_id === 'opp1')).toHaveLength(0); + }); + + it('an inactive rule never re-grants on touch (#4433)', async () => { + const r = await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', active: false, + }, SYS); + await rules.evaluateAllForRecord('opportunity', 'opp1', SYS); + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + expect(r.active).toBe(false); + }); + + /** + * objectstack#4433 — a grant whose rule row is GONE cannot be reached by + * reconciling rules (there is no rule left to iterate), so it needs its own + * sweep. These are the rows left by every path that removes a rule without + * `deleteRule`: a data-API delete while the hook was unbound, a migration, a + * crash between `deleteRule`'s two writes. + */ + it('sweepOrphanedRuleGrants revokes grants whose rule row is gone (#4433)', async () => { + const r = await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule(r.id, SYS); + const granted = engine._tables.sys_record_share.length; + expect(granted).toBeGreaterThan(0); + + // Rule row vanishes behind the service's back. + engine._tables.sys_sharing_rule = []; + + expect(await rules.sweepOrphanedRuleGrants()).toBe(granted); + expect(engine._tables.sys_record_share).toHaveLength(0); + }); + + it('sweepOrphanedRuleGrants leaves live rule grants and manual shares alone (#4433)', async () => { + const r = await rules.defineRule({ + name: 'hv', label: 'HV', object: 'opportunity', + criteria: { amount: { $gte: 100000 } }, + recipientType: 'team', recipientId: 'sales', + }, SYS); + await rules.evaluateRule(r.id, SYS); + const ruleGrants = engine._tables.sys_record_share.length; + // A hand-made share, plus a grant from a rule that no longer exists. + engine._tables.sys_record_share.push( + { id: 'manual1', object_name: 'opportunity', record_id: 'opp1', recipient_id: 'zoe', source: 'manual' }, + { id: 'orphan1', object_name: 'opportunity', record_id: 'opp1', recipient_id: 'zoe', source: 'rule', source_id: 'srule_gone' }, + ); + + expect(await rules.sweepOrphanedRuleGrants()).toBe(1); + expect(engine._tables.sys_record_share).toHaveLength(ruleGrants + 1); + expect(engine._tables.sys_record_share.some(s => s.id === 'manual1')).toBe(true); + expect(engine._tables.sys_record_share.some(s => s.id === 'orphan1')).toBe(false); + }); + it('inactive rule purges grants on evaluate', async () => { const r = await rules.defineRule({ name: 'hv', label: 'HV', object: 'opportunity', diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 3abf3d9678..2efecb761e 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -133,6 +133,17 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'system/settings-manifest.zod.ts:visible', ], }, + { + id: 'cel-bulk-action-visible', + summary: "selection-bar bulk action per-record eligibility (bulkActionDefs[].visible, objectui#3067)", + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: 'console (objectui) partitionBulkRows (plugin-grid/bulkEligibility.ts) → evalRowPredicate → @objectstack/formula celEngine (interpret), evaluated ONCE PER SELECTED RECORD with that record bound: the button is offered when at least one selected record passes, and the run covers only those — the rest are reported as skipped in the dialog. Faults hide the record (fallback:false, warnOnError) rather than acting on one the predicate was written to exclude; UI gating only, write enforcement stays with permissions/hooks', + // Reached the ledger in #4457, not #3067: the key existed and was evaluated + // all along, but it lived inside `bulkActionDefs: z.array(z.record(z.any()))`, + // so the conformance walk had no declared surface to see. Typing the def is + // what made it visible — which is the argument for typing it in one line. + covers: ['ui/bulk-action.zod.ts:visible'], + }, { id: 'cel-row-crud-visible', summary: 'built-in row Edit/Delete per-record visibility (userActions.{edit,delete}.visibleWhen, objectui#2614)', @@ -155,7 +166,12 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: [ 'automation/flow.zod.ts:condition', 'automation/sync.zod.ts:condition', - 'kernel/metadata-loader.zod.ts:filter', + // `kernel/metadata-loader.zod.ts:filter` (on MetadataLoadOptions and + // MetadataExportOptions) was removed with the rest of that file's + // zero-consumer duplicate envelope family in #4411. The surviving + // `system/metadata-persistence.zod` copies of those options never + // declared a `filter` — so no loader predicate was ever evaluated + // through this surface, and there is nothing to re-point at. ], }, { diff --git a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts index 9b7d741361..226f741a3b 100644 --- a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts +++ b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts @@ -21,20 +21,46 @@ import showcaseStack from '@objectstack/example-showcase'; import { SECRET_MASK } from '@objectstack/objectql'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { MATRIX } from './field-zoo.matrix'; +import { MATRIX, REFERENCE_TARGETS } from './field-zoo.matrix'; describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', () => { let stack: VerifyStack; let record: Record; + /** Resolved reference ids, keyed by field — the assertions compare to these. */ + const referenceIds: Record = {}; beforeAll(async () => { stack = await bootStack(showcaseStack); const token = await stack.signIn(); + // [#4441] Create a REAL row in each reference target first. + // + // The three relational entries used to write synthetic ids + // (`acc_synthetic_0001`, …) under a comment reading "FK enforcement is off + // in this harness". That comment described a HOLE, and #4441 closed it: a + // lookup / master_detail / tree pointing at a row that does not exist is + // now refused, so the fixture was relying on the very defect the platform + // now prevents. What this file actually proves — an id string round-trips + // as the same id string — is unchanged by using a real id, and the matrix + // stops depending on a bug. + for (const target of REFERENCE_TARGETS) { + const res = await stack.apiAs(token, 'POST', `/data/${target.object}`, target.body(referenceIds)); + expect( + res.status, + `could not seed ${target.object} for ${target.field}: ${res.status} ${await res.clone().text()}`, + ).toBeLessThan(300); + const json = (await res.json()) as { id?: string; record?: { id?: string } }; + const id = json.id ?? json.record?.id; + expect(id, `no id returned seeding ${target.object}`).toBeTruthy(); + referenceIds[target.field] = id as string; + } + // Build the create body from every entry that carries a `write` value // (+ required name). `present`/`computed` server-owned fields are skipped. + // A reference placeholder resolves to the id seeded above. const body: Record = { name: 'zoo-roundtrip' }; for (const c of MATRIX) { - if ('write' in c.check && c.check.write !== undefined) body[c.field] = c.check.write; + if (!('write' in c.check) || c.check.write === undefined) continue; + body[c.field] = c.field in referenceIds ? referenceIds[c.field] : c.check.write; } const created = await stack.apiAs(token, 'POST', '/data/showcase_field_zoo', body); @@ -62,7 +88,9 @@ describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', const actual = record[c.field]; switch (c.check.kind) { case 'equal': - expect(actual).toEqual(c.check.write); + expect(actual).toEqual( + c.field in referenceIds ? referenceIds[c.field] : c.check.write, + ); break; case 'setEqual': { // Array-typed fields: persisted as a JSON array; order is not diff --git a/packages/qa/dogfood/test/field-zoo.matrix.ts b/packages/qa/dogfood/test/field-zoo.matrix.ts index 7f0911b8d6..d41326b83c 100644 --- a/packages/qa/dogfood/test/field-zoo.matrix.ts +++ b/packages/qa/dogfood/test/field-zoo.matrix.ts @@ -18,6 +18,65 @@ export type Check = | { kind: 'masked'; write: unknown } // secret: POSTed plaintext must read back as SECRET_MASK | { kind: 'computed'; expected: unknown }; // derived, asserted not written +/** + * Stand-in for a reference id the suite only knows at runtime. + * + * The three relational entries below name a target object but cannot name a + * ROW: nothing exists until the suite creates one. The HTTP suite creates a row + * in each target and substitutes its real id — keyed on the field appearing in + * {@link REFERENCE_TARGETS}, which is the authoritative list, so this value is + * documentation rather than a control signal and can never leak to the wire. + * + * It is a STRING, deliberately. This table has two consumers: the HTTP suite, + * which substitutes, and `field-zoo-value-shape.test.ts`, which parses every + * `write` vector against the spec's `valueSchemaFor(type, 'stored')` WITHOUT + * booting a stack and therefore never substitutes. A `Symbol` placeholder + * satisfied the first and broke the second (`expected string, received + * symbol`) — and because the two live in different FILES, and dogfood shards by + * file, that surfaced as "shard 2 fixed, shard 1 regressed". A reference's + * stored form is an id string, so the placeholder is one. + */ +export const REFERENCE_PLACEHOLDER = 'zoo_reference_id_resolved_at_runtime'; + +/** + * Reference field → the object whose row supplies its id, and the minimal body + * that creates one. Consumed by the HTTP round-trip suite; the value-shape + * contract test ignores these entries (it never writes). + * + * ORDERED, and `body` is a factory, because the targets reference each other: + * `showcase_project` declares a REQUIRED lookup to `showcase_account`, so the + * account has to exist first and the project has to be given its real id. That + * dependency is itself a small proof of #4441 — seeding these in the wrong + * order now fails loudly instead of writing a project that points at nothing. + */ +export const REFERENCE_TARGETS: ReadonlyArray<{ + field: string; + object: string; + body: (seeded: Readonly>) => Record; +}> = [ + { + field: 'f_lookup', + object: 'showcase_account', + body: () => ({ name: 'zoo-ref-account', status: 'active' }), + }, + { + field: 'f_master_detail', + object: 'showcase_project', + body: (seeded) => ({ + name: 'zoo-ref-project', + // `planned` is the state machine's declared initial state — anything else + // is refused with `invalid_initial_state`. + status: 'planned', + account: seeded.f_lookup, + }), + }, + { + field: 'f_tree', + object: 'showcase_category', + body: () => ({ name: 'zoo-ref-category' }), + }, +]; + export interface FieldCase { field: string; type: string; @@ -88,12 +147,20 @@ export const MATRIX: FieldCase[] = [ { field: 'f_file', type: 'file', check: { kind: 'equal', write: 'file_zoo_doc' } }, { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: 'file_zoo_avatar' } }, // relational — store a reference id as a string and read it back verbatim. - // FK enforcement is off in this harness, so this asserts value fidelity - // (id string → id string), not referential integrity / $expand (covered - // elsewhere). The point here is the stored type doesn't drift. - { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: 'acc_synthetic_0001' } }, - { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: 'proj_synthetic_0001' } }, - { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: 'cat_synthetic_0001' } }, + // This asserts value fidelity (id string → id string), not `$expand`, which + // is covered elsewhere. The point here is that the stored type doesn't drift. + // + // The `write` values below are PLACEHOLDERS: the suite creates a real row in + // each target object and substitutes its id before the create, then asserts + // against that same id (see REFERENCE_TARGETS in the spec file). They used to + // be synthetic ids (`acc_synthetic_0001`, …) under a comment reading "FK + // enforcement is off in this harness" — which described a HOLE that #4441 + // closed: a lookup pointing at a row that does not exist is now refused. + // Round-tripping a REAL id proves the same fidelity and stops the matrix + // depending on a defect. + { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: REFERENCE_PLACEHOLDER } }, + { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: REFERENCE_PLACEHOLDER } }, + { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: REFERENCE_PLACEHOLDER } }, // security — both credential types mask on read (plaintext never echoes back // over HTTP). `secret` is encrypted at rest; `password` on a generic object is // plaintext at rest but masked to SECRET_MASK on read (ADR-0100 / #2036 — the diff --git a/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts new file mode 100644 index 0000000000..50fef79b44 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Durable suspended-run fixture (#4470). +// +// The gap this closes is a coverage seam, not a missing assertion: engine-side +// persistence was unit-tested against a FAKE table (`suspended-run-store.test.ts` +// runs suspend → restart → resume), and the approval chain was e2e-tested wholly +// in memory — while the ASSEMBLY between them (is `sys_automation_run` +// registered? is its table created? is the store actually attached to the +// engine?) was covered by nothing, because the verify harness pinned +// `suspendedRunStore: 'memory'` and so could not reach the durable path even in +// principle. #4420 grew in exactly that seam: the store hung off a table that +// was never created, every write failed into a `warn` nobody read, the pause +// "succeeded", and the run died at the next restart. +// +// So this fixture is deliberately the SMALLEST thing that makes the durable path +// observable: one object, one flow that suspends at a `screen` node, and a +// resume that must take a specific downstream effect. The proof boots it against +// a FILE-backed database, asserts the `paused` row really landed in +// `sys_automation_run` (not "no error was logged"), then cold-boots a second +// kernel over the same file and resumes there. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** The record the resumed half of the run stamps, so "it continued" is observable. */ +export const SuspendNote = ObjectSchema.create({ + name: 'suspend_note', + // [ADR-0090 D1] grandfather stamp: the gate under test is suspended-run + // durability, not owner-sharing. + sharingModel: 'public_read_write', + label: 'Suspend Note', + pluralLabel: 'Suspend Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + resolution: Field.text({ label: 'Resolution' }), + }, +}); + +/** + * `flow_durable_suspend` — start → screen (SUSPENDS) → update_record → end. + * + * The `screen` node is the cheapest node that pauses through the engine's + * durable-pause path (ADR-0019) without needing the approvals plugin, and it + * carries a declared field contract, so the resume also has to be a legitimate + * submission (#4477) rather than an empty poke. + * + * `noteId` arrives as a trigger input and is interpolated into the update + * filter, so a resume that continued the WRONG run (or lost its variable + * snapshot across the restart) cannot accidentally pass: the variables have to + * survive the round-trip through `sys_automation_run` for the right row to move. + */ +export const flowDurableSuspend = { + name: 'flow_durable_suspend', + label: 'Flow Durable Suspend', + type: 'screen', + status: 'active', + variables: [{ name: 'noteId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', + type: 'screen', + label: 'Resolution', + config: { + title: 'How was it resolved?', + fields: [ + { name: 'resolution', label: 'Resolution', type: 'text', required: true }, + ], + }, + }, + { + id: 'apply', + type: 'update_record', + label: 'Apply resolution', + config: { + objectName: 'suspend_note', + filter: { id: '{noteId}' }, + fields: { status: 'resolved', resolution: '{resolution}' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'apply' }, + { id: 'e3', source: 'apply', target: 'end' }, + ], +}; + +/** A minimal, self-contained app config the dogfood harness can boot twice. */ +export const durableSuspendStack = defineStack({ + manifest: { + id: 'com.dogfood.durable_suspend', + namespace: 'suspend', + version: '0.0.0', + type: 'app', + name: 'Durable Suspend Fixture', + description: 'Single-object app whose screen flow suspends, persists, and resumes after a cold boot (#4470).', + }, + objects: [SuspendNote], + flows: [flowDurableSuspend], +}); diff --git a/packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts b/packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts new file mode 100644 index 0000000000..206a70a9e3 --- /dev/null +++ b/packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// DURABLE SUSPENDED-RUN proof (#4470), end to end through the real HTTP + +// automation stack, against a FILE-backed database and a genuine cold boot. +// +// Why this exists is a statement about coverage, not about a missing assertion. +// Before it there was a clean seam nothing crossed: +// +// • unit tests covered ENGINE-side persistence (`suspended-run-store.test.ts` +// drives suspend → restart → resume against a fake table); +// • e2e covered the BUSINESS chain (approvals), but single-process and wholly +// in memory, because `packages/verify/src/harness.ts` pinned +// `suspendedRunStore: 'memory'` — so the durable path was STRUCTURALLY +// unreachable from this layer; +// • the ASSEMBLY between them — is the object registered, is the table +// created, is the store really attached — was covered by neither. +// +// #4420 grew in precisely that seam: the store hung off a table that was never +// created, every write failed into a `warn` nobody read, the pause reported +// success, and the run died at the next restart. #4460 added assembly UNIT +// tests; this is the e2e half. +// +// The assertions are therefore about FACTS rather than the absence of errors: +// the `paused` row is read back out of `sys_automation_run` by id and every +// field a rehydration would need is checked on it, the resume is shown to +// CONSUME that row (leaving the `run_`-prefixed history row in its place), and +// the screen contract is enforced against the persisted `screen_json`. +// +// KNOWN GAP — the literal cold boot. #4470's third bullet ("boot a new kernel, +// resume continues") is NOT asserted here, and deliberately not faked: a second +// `bootStack` over the same `databaseFile` reads a database whose tables exist +// but whose ROWS are gone, so it fails for a reason with nothing to do with +// suspended runs. Ordinary records do not survive it either, which is what +// identifies it as a harness/driver persistence gap rather than a defect in the +// suspended-run store. Filed as #4518; when it is fixed, the natural next test +// here is the one this file was originally written around. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { durableSuspendStack } from './fixtures/flow-durable-suspend-fixture.js'; + +describe('objectstack verify FLOW: suspended runs really reach the database (#4470)', () => { + let dir: string; + let dbFile: string; + /** The FIRST process: authors the record, triggers the flow, suspends. */ + let hot: VerifyStack | undefined; + let hotToken: string; + let noteId: string; + let runId: string; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-durable-suspend-')); + dbFile = join(dir, 'verify.sqlite'); + // No `suspendedRunStore` override: the harness now boots the plugin's own + // `'auto'` default, which is the wiring a real deployment gets. + hot = await bootStack(durableSuspendStack, { automation: true, databaseFile: dbFile }); + hotToken = await hot.signIn(); + }, 120_000); + + afterAll(async () => { + await hot?.stop().catch(() => {}); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('precondition: the automation service is wired and the flow is registered', async () => { + const res = await hot!.apiAs(hotToken, 'GET', '/automation/flow_durable_suspend'); + expect(res.status, `automation service not wired: ${res.status}`).toBe(200); + }); + + it('precondition: `sys_automation_run` really exists — the table #4420 was missing', async () => { + // The whole #4420 failure was a store writing into a table nobody created. + // Reading the object through the ordinary data route is the cheapest proof + // that the plugin's object registration actually reached schema sync. + const res = await hot!.apiAs(hotToken, 'GET', '/data/sys_automation_run?limit=1'); + expect(res.status, `sys_automation_run not queryable: ${await res.clone().text()}`).toBe(200); + }); + + it('suspends at the screen node and PERSISTS the pause as a `paused` row', async () => { + const created = await hot!.apiAs(hotToken, 'POST', '/data/suspend_note', { name: 'n1', status: 'new' }); + expect(created.status).toBeLessThan(300); + const cj = (await created.json()) as { id?: string; record?: { id?: string } }; + noteId = (cj.id ?? cj.record?.id) as string; + expect(noteId).toBeTruthy(); + + const triggered = await hot!.apiAs(hotToken, 'POST', '/automation/flow_durable_suspend/trigger', { + params: { noteId }, + }); + expect(triggered.status, await triggered.clone().text()).toBeLessThan(300); + const tj = (await triggered.json()) as any; + const result = tj.result ?? tj.data ?? tj; + expect(result.status).toBe('paused'); + runId = result.runId; + expect(runId, 'no runId on the paused result').toBeTruthy(); + + // THE assertion #4470 asked for: the pause is a ROW IN THE DATABASE, read + // back by id — not "no error was logged", which is exactly what #4420 + // produced while persisting nothing at all. + const row = await hot!.apiAs(hotToken, 'GET', `/data/sys_automation_run/${runId}`); + expect(row.status, `no sys_automation_run row for ${runId}`).toBe(200); + const rj = (await row.json()) as any; + const rec = rj.record ?? rj; + expect(rec.status).toBe('paused'); + expect(rec.flow_name).toBe('flow_durable_suspend'); + expect(rec.node_id).toBe('ask'); + // The resume gate (#3801) keys on the node TYPE, so it has to survive the + // restart the pause itself survives. + expect(rec.node_type).toBe('screen'); + // The variable snapshot must round-trip, or the resumed half cannot know + // which record it was working on. + expect(String(rec.variables_json ?? '')).toContain(noteId); + }); + + it('the durable row is what a rehydration would read — the whole SuspendedRun round-trips', async () => { + // The cold-boot half of #4470's ask cannot be asserted from this harness + // yet: file-backed sqlite-wasm data does not survive an in-process kernel + // restart here, so a second `bootStack` over the same file reads created + // tables with no rows (filed as a blocker — see the note at the end of this + // file). Rather than assert nothing, this pins the thing that failure mode + // would actually destroy: that every field `AutomationEngine` needs to + // REBUILD the pause is present and correctly shaped in the persisted row. + // + // That is exactly what #4420 lacked. Its store wrote into a table that did + // not exist, so the row was absent entirely; a row carrying the full + // continuation is the fact this proof exists to establish. + const row = await hot!.apiAs(hotToken, 'GET', `/data/sys_automation_run/${runId}`); + expect(row.status).toBe(200); + const rec = ((await row.json()) as any).record ?? {}; + + // The continuation: where to resume, under whose authority, with what state. + expect(rec.flow_name).toBe('flow_durable_suspend'); + expect(rec.node_id).toBe('ask'); + expect(rec.node_type).toBe('screen'); + expect(rec.status).toBe('paused'); + expect(rec.started_at).toBeTruthy(); + + // The variable snapshot and the step log both have to be parseable JSON — + // a store that stringified them wrongly would round-trip into a run whose + // downstream nodes see no variables at all. + const vars = JSON.parse(String(rec.variables_json)); + expect(vars.noteId).toBe(noteId); + expect(Array.isArray(JSON.parse(String(rec.steps_json)))).toBe(true); + + // `screen_json` is what a rehydrated pause validates a resume against + // (#4477), so the declared field contract must survive persistence too. + const screen = JSON.parse(String(rec.screen_json)); + expect(screen.nodeId).toBe('ask'); + expect(screen.fields.map((f: any) => f.name)).toContain('resolution'); + expect(screen.fields.find((f: any) => f.name === 'resolution').required).toBe(true); + }); + + it('the suspension is CONSUMED from the durable store on resume — no zombie row is left behind', async () => { + // The other half of durability, and the one #4420 turned into zombies: a + // row that outlives its run is a request the approvals sweep would later + // have to reason about. Resuming must delete it, not merely stop reading it. + const resumed = await hot!.apiAs( + hotToken, 'POST', `/automation/flow_durable_suspend/runs/${runId}/resume`, + { inputs: { resolution: 'fixed upstream' } }, + ); + expect(resumed.status, await resumed.clone().text()).toBeLessThan(300); + + // The downstream node ran with the snapshotted variable. + const note = await hot!.apiAs(hotToken, 'GET', `/data/suspend_note/${noteId}`); + const rec = ((await note.json()) as any).record ?? {}; + expect(rec.status).toBe('resolved'); + expect(rec.resolution).toBe('fixed upstream'); + + // The `paused` row is gone… + const gone = await hot!.apiAs(hotToken, 'GET', `/data/sys_automation_run/${runId}`); + expect(gone.status).toBe(404); + // …and the terminal history row took its place under the `run_` prefix, so + // "this run finished" stays answerable after a restart. + const history = await hot!.apiAs(hotToken, 'GET', `/data/sys_automation_run/run_${runId}`); + expect(history.status).toBe(200); + expect((((await history.json()) as any).record ?? {}).status).toBe('completed'); + }); + + it('enforces the screen contract on a run whose pause is durably stored (#4477 over the durable path)', async () => { + const created = await hot!.apiAs(hotToken, 'POST', '/data/suspend_note', { name: 'n2', status: 'new' }); + const id = ((await created.json()) as any).id; + const triggered = await hot!.apiAs(hotToken, 'POST', '/automation/flow_durable_suspend/trigger', { + params: { noteId: id }, + }); + const tj = (await triggered.json()) as any; + const second = (tj.result ?? tj.data ?? tj).runId; + + const bad = await hot!.apiAs( + hotToken, 'POST', `/automation/flow_durable_suspend/runs/${second}/resume`, { inputs: {} }, + ); + expect(bad.status).toBe(400); + expect(await bad.text()).toContain('resolution'); + + // Refused, not consumed — the durable row is still there and the + // legitimate submission still lands. + const still = await hot!.apiAs(hotToken, 'GET', `/data/sys_automation_run/${second}`); + expect(still.status).toBe(200); + const good = await hot!.apiAs( + hotToken, 'POST', `/automation/flow_durable_suspend/runs/${second}/resume`, + { inputs: { resolution: 'ok' } }, + ); + expect(good.status, await good.clone().text()).toBeLessThan(300); + }); +}); diff --git a/packages/rest/src/rest-meta-migrate-stored.test.ts b/packages/rest/src/rest-meta-migrate-stored.test.ts new file mode 100644 index 0000000000..0753394188 --- /dev/null +++ b/packages/rest/src/rest-meta-migrate-stored.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /api/v1/meta/_migrate-stored` — the server-side form of + * `os migrate meta --stored` (#4327 / #4454 / #4498). + * + * `os migrate meta --stored` needs shell access to the deployment's database. + * A hosted operator has none, so ADR-0087's stored-metadata chain had no finish + * line at all on a managed deployment — only the per-read conversion, running + * forever. This route is that finish line, and because it runs inside a server + * that already holds a live automation engine, flow rows are covered without + * threading anything (#4498). + * + * What these pin is the route's POSTURE. The migration itself is covered by + * `metadata-protocol`'s `protocol.stored-migration.test.ts`; here the questions + * are: who may fire it, and does an under-specified request write. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const REPORT = { + apply: false, + protocol: '17.0.0', + scanned: 4, + canonical: 3, + pending: 1, + rewritten: 0, + skipped: 0, + failed: 0, + rows: [], +}; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + let status = 200; + const res: any = { + status: (code: number) => { status = code; return res; }, + json: (body: any) => { (res as any)._json = body; return res; }, + header: () => res, + write: () => true, + end: () => {}, + }; + return { res, getStatus: () => status, getJson: () => (res as any)._json }; +} + +/** Boot the route over a stub protocol, with `execCtx` as the resolved caller. */ +function boot(execCtx: any, protocolOverrides: Record = {}) { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + const protocol: any = { migrateStoredMetadata, ...protocolOverrides }; + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => execCtx; + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'POST' && r.path === '/api/v1/meta/_migrate-stored', + ); + expect(route).toBeDefined(); + return { route, migrateStoredMetadata }; +} + +const run = async (route: any, body: unknown) => { + const out = makeRes(); + await route.handler({ params: {}, query: {}, body } as any, out.res); + return out; +}; + +describe('POST /meta/_migrate-stored — mounting', () => { + it('is registered BEFORE /meta/:type, so the segment is never read as a type name', async () => { + const rest = new RestServer( + createMockServer() as any, + { migrateStoredMetadata: vi.fn() } as any, + { api: { requireAuth: false } } as any, + ); + rest.registerRoutes(); + const paths = rest.getRoutes().map((r: any) => `${r.method} ${r.path}`); + expect(paths).toContain('POST /api/v1/meta/_migrate-stored'); + expect(paths.indexOf('POST /api/v1/meta/_migrate-stored')) + .toBeLessThan(paths.indexOf('GET /api/v1/meta/:type')); + }); +}); + +describe('POST /meta/_migrate-stored — capability gate', () => { + it('403s a caller without `manage_metadata`, and reads NOTHING', async () => { + const { route, migrateStoredMetadata } = boot({ userId: 'u1', systemPermissions: ['setup.access'] }); + const out = await run(route, { apply: true }); + + expect(out.getStatus()).toBe(403); + expect(out.getJson()).toMatchObject({ error: { code: 'FORBIDDEN' } }); + // Unlike the single-item `PUT /meta/:type/:name` next door, this rewrites + // every eligible row in the deployment — a session is not enough. + expect(migrateStoredMetadata).not.toHaveBeenCalled(); + }); + + it('an anonymous caller never reaches the capability gate — 401 from the meta umbrella', async () => { + // Every `/meta` route inherits the anonymous-deny wrapper + // (`registerMetadataEndpoints`), so this route is closed to anonymous + // callers by construction and the `manage_metadata` check below it is the + // second layer, not the only one. + const { route, migrateStoredMetadata } = boot(undefined); + const out = await run(route, {}); + expect(out.getStatus()).toBe(401); + expect(migrateStoredMetadata).not.toHaveBeenCalled(); + }); + + it('allows a caller holding `manage_metadata`', async () => { + const { route, migrateStoredMetadata } = boot({ userId: 'u1', systemPermissions: ['manage_metadata'] }); + const out = await run(route, {}); + expect(out.getStatus()).toBe(200); + expect(migrateStoredMetadata).toHaveBeenCalledTimes(1); + }); + + it('isSystem bypasses, matching every other capability gate', async () => { + const { route, migrateStoredMetadata } = boot({ isSystem: true }); + await run(route, {}); + expect(migrateStoredMetadata).toHaveBeenCalledTimes(1); + }); + + it('the gate fires BEFORE the protocol is probed, so 403 vs 501 leaks nothing', async () => { + // A kernel with no `migrateStoredMetadata` answers 501 to an authorized + // caller. An unauthorized one must not be able to tell the two apart. + const { route } = boot({ userId: 'u1', systemPermissions: [] }, { migrateStoredMetadata: undefined }); + const out = await run(route, {}); + expect(out.getStatus()).toBe(403); + }); +}); + +describe('POST /meta/_migrate-stored — preview by default', () => { + const admin = { userId: 'admin', systemPermissions: ['manage_metadata'] }; + + it('an empty body previews — `apply` is never inferred', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, {}); + expect(migrateStoredMetadata.mock.calls[0][0].apply).toBe(false); + }); + + it('a missing body previews rather than throwing', async () => { + const { route, migrateStoredMetadata } = boot(admin); + const out = await run(route, undefined); + expect(out.getStatus()).toBe(200); + expect(migrateStoredMetadata.mock.calls[0][0].apply).toBe(false); + }); + + it('only a literal `true` applies', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, { apply: 'yes' }); + expect(migrateStoredMetadata.mock.calls[0][0].apply).toBe(false); + await run(route, { apply: true }); + expect(migrateStoredMetadata.mock.calls[1][0].apply).toBe(true); + }); + + it('passes a `types` filter through, dropping non-string members', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, { types: ['flow', 42, '', 'object'] }); + expect(migrateStoredMetadata.mock.calls[0][0].types).toEqual(['flow', 'object']); + }); + + it('omits `types` when none survive, so the run is not silently empty', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, { types: [42] }); + expect(migrateStoredMetadata.mock.calls[0][0]).not.toHaveProperty('types'); + }); + + it('threads NO canonicalizeFlow — the protocol resolves the engine itself (#4498)', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, {}); + expect(migrateStoredMetadata.mock.calls[0][0]).not.toHaveProperty('canonicalizeFlow'); + }); + + it('attributes the run to the caller — history and audit rows answer "who ran it"', async () => { + const { route, migrateStoredMetadata } = boot(admin); + await run(route, {}); + expect(migrateStoredMetadata.mock.calls[0][0].actor).toContain('admin'); + }); + + it('returns the report unwrapped', async () => { + const { route } = boot(admin); + const out = await run(route, {}); + expect(out.getJson()).toEqual(REPORT); + }); + + it('501s an authorized caller on a kernel whose protocol predates the pass', async () => { + const { route } = boot(admin, { migrateStoredMetadata: undefined }); + const out = await run(route, {}); + expect(out.getStatus()).toBe(501); + expect(out.getJson()).toMatchObject({ error: { code: 'NOT_IMPLEMENTED' } }); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 3d20e5ecc3..37b5aa031d 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -85,6 +85,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' }, { route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getDiagnostics' }, { route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts' }, + { route: 'POST /api/v1/meta/_migrate-stored', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateStored', + note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }' }, { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences' }, { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree' }, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5d36d8ceb9..c79c1e7fa1 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2808,6 +2808,90 @@ export class RestServer { }); } + // POST /meta/_migrate-stored — rewrite stored sys_metadata rows into + // today's canonical shape (ADR-0087; #4327 / #4454 / #4498). + // + // The server-side form of `os migrate meta --stored`. The CLI form + // needs shell access to the deployment's database, which a hosted + // operator does not have — so without this route the stored-metadata + // chain has no finish line on a managed deployment, only the per-read + // conversion that runs forever. Flow rows are covered here for free: + // `migrateStoredMetadata` resolves the automation engine from the + // services registry (#4498), and a server always has a live one. + // + // Registered BEFORE `/meta/:type` so the leading-underscore segment is + // not captured as a `:type` parameter (same reason as `_drafts`). + if (metadata.endpoints.items !== false) { + this.routeManager.register({ + method: 'POST', + path: `${metaPath}/_migrate-stored`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // Gate FIRST — before resolving the protocol — so an + // unauthorized caller cannot use the 501 vs 200 answer + // to probe which kernels can be migrated. + // + // This rewrites every eligible row in the deployment, + // so unlike the single-item `PUT /meta/:type/:name` it + // demands an explicit capability rather than only a + // session. `manage_metadata` is ADR-0066 D1's authoring + // capability, and a canonicalization rewrite is + // authoring; `isSystem` bypasses, matching every other + // capability gate on the platform. + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const held = new Set( + Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], + ); + if (!ctx?.isSystem && !held.has('manage_metadata')) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Rewriting stored metadata requires the `manage_metadata` capability.', + }, + }); + return; + } + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).migrateStoredMetadata !== 'function') { + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'protocol.migrateStoredMetadata() is not available in this kernel', + }, + }); + return; + } + const rawTypes = (req.body as any)?.types; + const types = Array.isArray(rawTypes) + ? rawTypes.filter((t: unknown): t is string => typeof t === 'string' && t.length > 0) + : []; + // Preview by default — `apply` must be explicitly true, + // the same posture the CLI takes. A caller who sends an + // empty body gets a report and no writes. + const report = await (p as any).migrateStoredMetadata({ + apply: (req.body as any)?.apply === true, + ...(types.length > 0 ? { types } : {}), + // Attributed to the caller: this writes history + + // audit rows, and "who ran the migration" is the + // question those rows exist to answer. + actor: ctx?.userId + ? `${ctx.userId} (POST ${metadata.prefix}/_migrate-stored)` + : `POST ${metadata.prefix}/_migrate-stored`, + }); + res.json(report); + } catch (error: any) { + logError("[REST] Unhandled error:", error); + sendError(res, error); + } + }, + metadata: { + summary: 'Rewrite stored metadata rows into the canonical protocol shape', + tags: ['metadata'], + }, + }); + } + // GET /meta/:type - List items of a type if (metadata.endpoints.items !== false) { this.routeManager.register({ @@ -6731,6 +6815,14 @@ export class RestServer { [/^THROTTLED/, 429, 'THROTTLED'], [/^FORBIDDEN/, 403, 'FORBIDDEN'], [/^REQUEST_NOT_FOUND/, 404, 'REQUEST_NOT_FOUND'], + // #4420 — the request and its flow run disagree about whether + // the work can still proceed. A conflict, like INVALID_STATE: + // the row is fine, the run behind it is not. + [/^RESUME_TARGET_LOST/, 409, 'RESUME_TARGET_LOST'], + // The outcome IS recorded and its run is stranded — a genuine + // server-side inconsistency, but named, so the client can say + // which run needs an operator instead of showing a bare 500. + [/^RESUME_FAILED/, 500, 'RESUME_FAILED'], ]; for (const [re, status, code] of mapping) { if (re.test(msg)) { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index a0a4a7eadc..2ba0d0bc66 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -374,6 +374,49 @@ export class AppPlugin implements Plugin { ctx.logger.debug('[AppPlugin] Installed hook-metrics Server-Timing feed'); } + /** + * Datasource name → the objects a `datasourceMapping` rule routes to it + * (#4462), asked of the ENGINE rather than re-derived here. + * + * The gate this feeds (`isDatasourceAddressed` (d)) and the routing that + * makes it correct (`ObjectQLEngine.getDriver` step 2) must agree exactly + * about which rules match which objects. A second matcher living in this + * plugin — or in the connection service — would drift by one clause and + * produce either a datasource connected that routing never uses, or one + * routed to and never connected, which is the defect itself. + * + * Objects with an EXPLICIT `object.datasource` binding are excluded: that + * binding outranks mapping in `getDriver`, so counting them here would let + * a mapping rule they never obey force a fail-fast on their behalf. + * `default` is excluded for the same reason `getDriver` lets it through — + * the host's default driver is registered under its natural name and needs + * no per-app connect. + */ + private resolveMappedObjects( + ql: IObjectQLEngine, + objects: Array<{ name?: string; datasource?: string }>, + ): Record { + const resolve = (ql as unknown as { + resolveMappedDatasource?: (objectName: string) => string | null; + }).resolveMappedDatasource; + if (typeof resolve !== 'function') return {}; + const out: Record = {}; + for (const obj of objects) { + const name = obj?.name; + if (typeof name !== 'string' || !name) continue; + if (obj.datasource && obj.datasource !== 'default') continue; + let mapped: string | null = null; + try { + mapped = resolve.call(ql, name); + } catch { + continue; // a resolver that throws must not brick boot + } + if (!mapped || mapped === 'default') continue; + (out[mapped] ??= []).push(name); + } + return out; + } + start = async (ctx: PluginContext) => { if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', { @@ -480,10 +523,10 @@ export class AppPlugin implements Plugin { // + register a live driver via the shared `'datasource-connection'` // service (when present — wired by the datasource-admin plugin). The // service applies the D2 gate (connect only when `external`, an object - // explicitly binds via `object.datasource`, or `autoConnect:true`) and - // the host connect policy, so managed+unrouted datasources stay - // metadata-only (e.g. app-crm's `:memory:` datasources — byte-for-byte - // unchanged). Idempotent vs. a legacy `onEnable` driver registration. + // explicitly binds via `object.datasource`, a `datasourceMapping` rule + // routes objects to it, or `autoConnect:true`) and the host connect + // policy, so a managed datasource nothing routes to stays metadata-only. + // Idempotent vs. a legacy `onEnable` driver registration. // // Runs in `start()` (before the `kernel:ready` external-validation gate) // so the kernel's init-all-then-start-all ordering guarantees the @@ -506,6 +549,7 @@ export class AppPlugin implements Plugin { connectDeclared?: (input: { datasources: any[]; objects?: Array<{ name?: string; datasource?: string }>; + mappedObjects?: Record; }) => Promise>; } | undefined; @@ -516,7 +560,11 @@ export class AppPlugin implements Plugin { } if (typeof connection?.connectDeclared === 'function') { const objects = Array.isArray(this.bundle.objects) ? this.bundle.objects : []; - const results = await connection.connectDeclared({ datasources: dsList, objects }); + const results = await connection.connectDeclared({ + datasources: dsList, + objects, + mappedObjects: this.resolveMappedObjects(ql, objects), + }); const connected = results.filter((r) => r.status === 'connected'); if (connected.length > 0) { ctx.logger.info('Auto-connected declared datasources', { diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 70c596f911..4336c5bf61 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -315,13 +315,22 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // spreading the body) keeps that unforgeable even if a caller invents // extra keys. // - // Two REFUSAL codes come back from the engine and are answered as such + // REFUSAL codes come back from the engine and are answered as such // rather than a 200 carrying `success: false` (which reads as "your // resume ran and the flow failed"): - // forbidden → 403, the suspension is service-owned (#3801) - // invalid_signal → 400, the signal wrote the engine's `$` variable - // namespace (#3853 follow-up) - // Both are enforced in the ENGINE, at the one place a signal reaches the + // PERMISSION_DENIED → 403, the suspension is service-owned (#3801) + // INVALID_SIGNAL → 400, the signal wrote the engine's `$` variable + // namespace (#3853 follow-up) + // INVALID_SCREEN_INPUT → 400, the bag violates the suspended screen's + // declared field contract — a required field the + // caller was asked for is missing, or an + // undeclared key was sent (#4477) + // RUN_NOT_FOUND → 404, no such suspension — unresumable for good + // STORE_UNAVAILABLE → 503, the durable store is unreadable, so + // existence is unknown; the same call is expected + // to work once it recovers (#4420) + // RESUME_IN_PROGRESS → 409, a concurrent resume already has this run + // All are enforced in the ENGINE, at the one place a signal reaches the // variable map — deliberately not re-implemented here. Guarding a field // at a time in the transport is what let `output` reopen the hole // `inputs` had just closed; every transport now inherits one rule. @@ -340,6 +349,18 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (result?.success === false && result.code === 'INVALID_SIGNAL') { return { handled: true, response: deps.error(result.error ?? 'Invalid resume signal', 400) }; } + if (result?.success === false && result.code === 'INVALID_SCREEN_INPUT') { + return { handled: true, response: deps.error(result.error ?? 'Invalid screen input', 400) }; + } + if (result?.success === false && result.code === 'RUN_NOT_FOUND') { + return { handled: true, response: deps.error(result.error ?? 'No such suspended run', 404) }; + } + if (result?.success === false && result.code === 'STORE_UNAVAILABLE') { + return { handled: true, response: deps.error(result.error ?? 'Suspended-run store unavailable', 503) }; + } + if (result?.success === false && result.code === 'RESUME_IN_PROGRESS') { + return { handled: true, response: deps.error(result.error ?? 'Run is already being resumed', 409) }; + } return { handled: true, response: deps.success(result) }; } return { handled: true, response: deps.error('Resume not supported', 501) }; diff --git a/packages/runtime/src/domains/meta-migrate-stored.test.ts b/packages/runtime/src/domains/meta-migrate-stored.test.ts new file mode 100644 index 0000000000..1b7a4f5515 --- /dev/null +++ b/packages/runtime/src/domains/meta-migrate-stored.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /meta/_migrate-stored` — the server-side form of + * `os migrate meta --stored` (#4327 / #4454 / #4498). + * + * The CLI form needs shell access to the deployment's database, which a hosted + * operator does not have, so on a managed deployment ADR-0087's stored-metadata + * chain had no finish line at all. This route is that finish line — and, unlike + * the CLI, it runs in a process that already holds a live automation engine, so + * flow rows are covered without threading anything (#4498). + * + * What is pinned here is the route's POSTURE, not the migration itself (that is + * `metadata-protocol`'s `protocol.stored-migration.test.ts`): it rewrites every + * eligible row in the deployment, so it must demand a capability rather than a + * session, and it must not write unless the caller explicitly asked it to. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const REPORT = { + apply: false, + protocol: '17.0.0', + scanned: 3, + canonical: 2, + pending: 1, + rewritten: 0, + skipped: 0, + failed: 0, + rows: [], +}; + +function make(protocol: any) { + const kernel = { + context: { + getService: (name: string) => (name === 'protocol' ? protocol : null), + }, + } as any; + return new HttpDispatcher(kernel); +} + +const ctx = (executionContext: any): any => ({ request: {}, environmentId: 'platform', executionContext }); + +describe('POST /meta/_migrate-stored — capability gate (#4327)', () => { + it('403s an authenticated caller without `manage_metadata`', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + const res = await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', + ctx({ userId: 'u1', systemPermissions: ['setup.access'] }), + 'POST', + { apply: true }, + ); + expect(res.response.status).toBe(403); + // The gate is the point: an ordinary session must not be able to rewrite + // every metadata row in the deployment. + expect(migrateStoredMetadata).not.toHaveBeenCalled(); + }); + + it('allows a caller holding `manage_metadata`', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + const res = await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', + ctx({ userId: 'u1', systemPermissions: ['manage_metadata'] }), + 'POST', + {}, + ); + expect(res.response.status).not.toBe(403); + expect(migrateStoredMetadata).toHaveBeenCalledTimes(1); + }); + + it('engine self-invocation (isSystem) bypasses, matching every other capability gate', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', + ctx({ isSystem: true }), + 'POST', + {}, + ); + expect(migrateStoredMetadata).toHaveBeenCalledTimes(1); + }); + + it('an anonymous caller is refused by the domain gate before reaching this route', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + const res = await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', + ctx({}), + 'POST', + {}, + ); + expect(res.response.status).toBe(401); + expect(migrateStoredMetadata).not.toHaveBeenCalled(); + }); +}); + +describe('POST /meta/_migrate-stored — preview by default (#4327)', () => { + const admin = () => ctx({ userId: 'admin', systemPermissions: ['manage_metadata'] }); + + it('an empty body previews — `apply` is never inferred', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata('/_migrate-stored', admin(), 'POST', {}); + expect(migrateStoredMetadata.mock.calls[0][0].apply).toBe(false); + }); + + it('only a literal `true` applies — a truthy string does not', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + const d = make({ migrateStoredMetadata }); + await d.handleMetadata('/_migrate-stored', admin(), 'POST', { apply: 'yes' }); + expect(migrateStoredMetadata.mock.calls[0][0].apply).toBe(false); + await d.handleMetadata('/_migrate-stored', admin(), 'POST', { apply: true }); + expect(migrateStoredMetadata.mock.calls[1][0].apply).toBe(true); + }); + + it('passes a `types` filter through, dropping non-string members', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', admin(), 'POST', { types: ['flow', 42, '', 'object'] }, + ); + expect(migrateStoredMetadata.mock.calls[0][0].types).toEqual(['flow', 'object']); + }); + + it('omits `types` entirely when none survive, so the run is not silently empty', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata( + '/_migrate-stored', admin(), 'POST', { types: [42] }, + ); + expect(migrateStoredMetadata.mock.calls[0][0]).not.toHaveProperty('types'); + }); + + it('threads NO canonicalizeFlow — the protocol resolves the engine itself (#4498)', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata('/_migrate-stored', admin(), 'POST', {}); + expect(migrateStoredMetadata.mock.calls[0][0]).not.toHaveProperty('canonicalizeFlow'); + }); + + it('attributes the run to the caller — history and audit rows answer "who ran it"', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata }).handleMetadata('/_migrate-stored', admin(), 'POST', {}); + expect(migrateStoredMetadata.mock.calls[0][0].actor).toContain('admin'); + }); + + it('returns the report as-is', async () => { + const res = await make({ migrateStoredMetadata: vi.fn().mockResolvedValue(REPORT) }) + .handleMetadata('/_migrate-stored', admin(), 'POST', {}); + expect(res.response.status).toBe(200); + expect(res.response.body.data).toEqual(REPORT); + }); + + it('501s on a kernel whose protocol predates the pass', async () => { + const res = await make({}).handleMetadata('/_migrate-stored', admin(), 'POST', {}); + expect(res.response.status).toBe(501); + }); + + it('is POST-only — a GET falls through to the type-list handler, not a rewrite', async () => { + const migrateStoredMetadata = vi.fn().mockResolvedValue(REPORT); + await make({ migrateStoredMetadata, getMetaItems: vi.fn().mockResolvedValue({ items: [] }) }) + .handleMetadata('/_migrate-stored', admin(), 'GET'); + expect(migrateStoredMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 6944c31e15..1ef489ec5c 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -294,6 +294,62 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Draft listing not supported', 501) }; } + // POST /metadata/_migrate-stored (#4327 / #4454 / #4498) + // + // The server-side entry point to the same canonicalization pass + // `os migrate meta --stored` runs. It exists because the CLI form requires + // shell access to the deployment's database, which a hosted operator does + // not have — so on a managed deployment ADR-0087's stored-metadata chain + // had no finish line at all, only the per-read conversion that never ends. + // + // Nothing about flows is threaded through here: `migrateStoredMetadata` + // resolves the automation engine from the services registry (#4498), and a + // server always has a live one — so this route covers flow rows by simply + // running in the process that owns them. + // + // Body: `{ apply?: boolean, types?: string[] }`. **Preview by default** — + // the same posture as the CLI: `apply` must be explicitly `true`, and a + // caller who sends nothing gets a report and no writes. + if (parts.length === 1 && parts[0] === '_migrate-stored' && method?.toUpperCase() === 'POST') { + // This rewrites every eligible `sys_metadata` row in the deployment, so + // unlike the single-item `PUT /metadata/:type/:name` next door it is + // gated on an explicit capability rather than on being authenticated. + // `manage_metadata` is the ADR-0066 D1 capability for authoring and + // publishing metadata, which is exactly what a rewrite is; engine + // self-invocation (`isSystem`) bypasses, matching `actionPermissionError`. + const ec: any = _context.executionContext; + if (!ec?.isSystem && !new Set(ec?.systemPermissions ?? []).has('manage_metadata')) { + return { + handled: true, + response: deps.error( + 'Rewriting stored metadata requires the `manage_metadata` capability.', + 403, + ), + }; + } + + const protocol = await deps.resolveService('protocol'); + if (!protocol || typeof (protocol as any).migrateStoredMetadata !== 'function') { + return { handled: true, response: deps.error('Stored-metadata migration not supported', 501) }; + } + const types = Array.isArray(body?.types) + ? body.types.filter((t: unknown): t is string => typeof t === 'string' && t.length > 0) + : undefined; + try { + const report = await (protocol as any).migrateStoredMetadata({ + apply: body?.apply === true, + ...(types && types.length > 0 ? { types } : {}), + // Attributed to the caller, not to the route: this writes + // history + audit rows, and "who ran the migration" is the + // question those rows exist to answer. + actor: ec?.userId ? `${ec.userId} (POST /metadata/_migrate-stored)` : 'POST /metadata/_migrate-stored', + }); + return { handled: true, response: deps.success(report) }; + } catch (e: any) { + return { handled: true, response: deps.errorFromThrown(e, 500) }; + } + } + // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) if (parts.length === 1) { const typeOrName = parts[0]; diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 1b80da7f7f..41507b0568 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2326,12 +2326,20 @@ describe('HttpDispatcher', () => { it('says nothing ships rather than naming a package that does not exist', async () => { const info = await dispatcher.getDiscoveryInfo('/api/v1'); - for (const slot of ['ai', 'search', 'workflow'] as const) { + // `workflow` left this list with its slot (#4451, v17). + for (const slot of ['ai', 'search'] as const) { expect(info.services[slot].message, `services.${slot}.message`).not.toMatch(/Install/); expect(info.services[slot].message, `services.${slot}.message`).toContain(slot); } }); + it('reports no entry at all for the retired workflow slot (#4451)', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services).not.toHaveProperty('workflow'); + expect(info.routes).not.toHaveProperty('workflow'); + expect(info.features).not.toHaveProperty('workflow'); + }); + it('never emits the old slot-name-derived template', async () => { const info = await dispatcher.getDiscoveryInfo('/api/v1'); for (const [slot, entry] of Object.entries(info.services as Record)) { @@ -2520,15 +2528,17 @@ describe('HttpDispatcher', () => { }); it('keeps reporting unmarked services as available', async () => { + // Was pinned on `workflow` until that slot retired (#4451, v17); + // `auth` exercises the same unmarked-service path. (kernel as any).getService = vi.fn().mockImplementation((name: string) => { - if (name === 'workflow') return { getConfig: vi.fn() }; + if (name === 'auth') return { validateToken: vi.fn() }; return null; }); const info = await dispatcher.getDiscoveryInfo('/api/v1'); - expect(info.services.workflow.enabled).toBe(true); - expect(info.services.workflow.status).toBe('available'); - expect(info.services.workflow.handlerReady).toBe(true); + expect(info.services.auth.enabled).toBe(true); + expect(info.services.auth.status).toBe('available'); + expect(info.services.auth.handlerReady).toBe(true); }); // ── The `metadata` slot: computed, not hardcoded (#4089) ────────────── @@ -2668,8 +2678,9 @@ describe('HttpDispatcher', () => { // `available`. Table-driven so the next fallback added to the table is // gated the day it lands; this class of hole recurs with every new // fallback. cache/queue/job had no per-slot pin before this — dropping - // their `svcAvailable(…, svc)` third argument, the exact #4130 - // regression shape, was test-invisible. + // their occupant argument (`svcAvailable(…, svc)` then, + // `svcInProcess(slot, svc)` since #4318), the exact #4130 regression + // shape, was test-invisible. it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => { const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); @@ -2688,6 +2699,56 @@ describe('HttpDispatcher', () => { expect(reported.message, `services.${slot}.message`).toBeTruthy(); } }); + + // ── Kernel-internal slots (#4318): no route, handlerReady is the fact ── + // + // service-cache/-queue/-job mount no HTTP routes — the slots are + // in-process contracts, so no route is ever advertised for them and + // `handlerReady` is `false` as a fact, not a proxy. `svcAvailable` + // used to claim `handlerReady: true` for an unmarked occupant here — a + // handler that does not exist. The status stays `available` for an + // unmarked real implementation: "no HTTP surface" is not reduced + // capability for an in-process contract (contrast realtime). + it('reports unmarked cache/queue/job occupants available with no route and handlerReady false (#4318)', async () => { + for (const slot of ['cache', 'queue', 'job']) { + const svc = { /* real, unmarked */ }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null)); + (kernel as any).services = new Map([[slot, svc]]); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + const reported = (info.services as Record)[slot]; + expect(reported.enabled, `services.${slot}.enabled`).toBe(true); + expect(reported.status, `services.${slot}.status`).toBe('available'); + expect(reported.handlerReady, `services.${slot}.handlerReady`).toBe(false); + expect(reported.route, `services.${slot}.route`).toBeUndefined(); + expect(reported.message, `services.${slot}.message`).toContain('no HTTP surface'); + } + }); + + it('answers the cache/queue/job slots identically to the metadata-protocol builder (#4318)', async () => { + const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol'); + const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); + + for (const slot of ['cache', 'queue', 'job']) { + // Both shapes an occupant can take: a real (unmarked) service + // and the kernel's self-describing in-memory fallback. + for (const svc of [{}, CORE_FALLBACK_FACTORIES[slot]()]) { + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null)); + (kernel as any).services = new Map([[slot, svc]]); + + const fromDispatcher = ((await dispatcher.getDiscoveryInfo('/api/v1')).services as Record)[slot]; + const fromProtocol = (await new ObjectStackProtocolImplementation( + mockObjectQL as any, + () => new Map([[slot, svc]]), + ).getDiscovery()).services[slot]; + + expect(fromDispatcher.status, `${slot}.status`).toBe(fromProtocol.status); + expect(fromDispatcher.handlerReady, `${slot}.handlerReady`).toBe(fromProtocol.handlerReady); + expect(fromDispatcher.message, `${slot}.message`).toBe(fromProtocol.message); + expect(fromDispatcher.route, `${slot}.route`).toBe(fromProtocol.route); + } + } + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 58312de3a7..23887a75d9 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -5,7 +5,7 @@ import { } from '@objectstack/core'; import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; -import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system'; +import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; @@ -876,7 +876,7 @@ export class HttpDispatcher { // that request handlers (handleI18n, handleAuth, …) use. const [ authSvc, searchSvc, realtimeSvc, filesSvc, - analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, + analyticsSvc, aiSvc, notificationSvc, i18nSvc, protocolSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc, metadataSvc, dataSvc, ] = await Promise.all([ @@ -885,7 +885,6 @@ export class HttpDispatcher { this.resolveService(CoreServiceName.enum.realtime), this.resolveService(CoreServiceName.enum['file-storage']), this.resolveService(CoreServiceName.enum.analytics), - this.resolveService(CoreServiceName.enum.workflow), this.resolveService(CoreServiceName.enum.ai), this.resolveService(CoreServiceName.enum.notification), this.resolveService(CoreServiceName.enum.i18n), @@ -943,7 +942,6 @@ export class HttpDispatcher { const automationRegistered = !!automationSvc; const hasFiles = isServiceServeable(filesSvc); const hasAnalytics = isServiceServeable(analyticsSvc); - const hasWorkflow = !!workflowSvc; const hasAi = isServiceServeable(aiSvc); const hasNotification = isServiceServeable(notificationSvc); const hasI18n = isServiceServeable(i18nSvc); @@ -977,7 +975,9 @@ export class HttpDispatcher { storage: hasFiles ? `${prefix}/storage` : undefined, analytics: hasAnalytics ? `${prefix}/analytics` : undefined, automation: hasAutomation ? `${prefix}/automation` : undefined, - workflow: hasWorkflow ? `${prefix}/workflow` : undefined, + // `workflow` removed (#4451, v17): the slot retired — nothing + // ever registered it and this dispatcher never had a /workflow + // branch, so the advertisement could never come true. // Never advertised (ADR-0076 D12, #2462): service-realtime is an // in-process pub/sub bus — the dispatcher has no /realtime branch // and no plugin mounts one, so an advertised route would 404. @@ -1037,6 +1037,23 @@ export class HttpDispatcher { enabled: false, status: 'unavailable' as const, handlerReady: false, message: serviceUnavailableMessage(name), }); + // [#4318] Kernel-internal slots (cache/queue/job): their providers + // mount no HTTP routes, so no route is advertised and `handlerReady` + // is `false` as a fact, not a proxy — `svcAvailable` would claim a + // handler that does not exist. An unmarked occupant stays `available`: + // the slot's contract is in-process, so "no HTTP surface" is not + // reduced capability (contrast `realtime` below, whose advertised + // capability IS the missing surface). Message written once in + // `@objectstack/spec/system` so both discovery builders agree. + const svcInProcess = (name: string, svc: unknown) => { + const self = svc ? readServiceSelfInfo(svc) : undefined; + return { + enabled: true, + status: self?.status ?? ('available' as const), + handlerReady: false, + message: self?.message ?? inProcessServiceMessage(name), + }; + }; // Self-description of the registered realtime service, if any (D12). const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined; @@ -1073,7 +1090,6 @@ export class HttpDispatcher { files: hasFiles, analytics: hasAnalytics, ai: hasAi, - workflow: hasWorkflow, notifications: hasNotification, i18n: hasI18n, }, @@ -1148,9 +1164,9 @@ export class HttpDispatcher { // "install a plugin" would say strictly less. automation: automationRegistered ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'), analytics: analyticsRegistered ? svcAvailable(routes.analytics, undefined, analyticsSvc) : svcUnavailable('analytics'), - cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'), - queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'), - job: hasJob ? svcAvailable(undefined, undefined, jobSvc) : svcUnavailable('job'), + cache: hasCache ? svcInProcess('cache', cacheSvc) : svcUnavailable('cache'), + queue: hasQueue ? svcInProcess('queue', queueSvc) : svcUnavailable('queue'), + job: hasJob ? svcInProcess('job', jobSvc) : svcUnavailable('job'), // [#4093] Reported from what serves it, like the route above: // `/ui` is a dispatcher domain answered by the `protocol` // service, so its self-description (none today — MetadataPlugin @@ -1164,7 +1180,8 @@ export class HttpDispatcher { enabled: false, status: 'unavailable' as const, handlerReady: false, message: serviceUnavailableMessage('ui'), }, - workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'), + // `workflow` entry removed (#4451, v17) with the slot itself — + // it could only ever report `unavailable`. // Honest entry (ADR-0076 D12, #2462): the registered realtime // service is an in-process event bus with NO mounted HTTP/WS // surface — report it degraded with handlerReady:false (or as diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index f26d3764aa..bcd6d33b86 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -219,6 +219,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem' }, { route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'sdk', client: 'meta.getPublished' }, { route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts' }, + { route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored', + note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }' }, { route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates' }, // ── data (legacy chain) ─────────────────────────────────────────────────── diff --git a/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts b/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts new file mode 100644 index 0000000000..f581787878 --- /dev/null +++ b/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4431] A capability denial is the sandbox FAULTING, not the body rejecting. + * + * The `action-crash-vs-rejection` contract (#3951) pins the table: + * + * | `SandboxError` WITH `innerMessage` — a body's deliberate throw | 400 | + * | `SandboxError` with NO `innerMessage` — timeout, capability denial | 500 | + * + * A capability gate throws `SandboxError` synchronously inside a QuickJS host + * function, which rejects the async IIFE *inside* the VM — so it came back + * through the `__error` side-channel, and the pump loop presumed everything + * arriving there was user code throwing on purpose. It therefore set + * `innerMessage`, the dispatcher's classifier read that as a deliberate + * rejection and answered **400**, and the client got the `SandboxError: ` debug + * prefix that only ever belonged in server logs. + * + * The contract's own claim — "capability denial has no user-meaningful inner + * message" — held only for denials detected OUTSIDE evaluation (a timeout, + * which takes the separate `budgetError` path). These tests pin the in-VM + * host-call denials that were misclassified: `ctx.api.*`, `ctx.log`, + * `ctx.crypto`, `ctx.api.transaction`. + * + * The HTTP half of the contract is asserted by + * `domains/actions-fault-vs-rejection.test.ts`, which already states that a + * `SandboxError` with no `innerMessage` is a 500 — this file is what makes a + * real capability denial actually arrive in that shape. + */ + +import { describe, it, expect } from 'vitest'; +import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js'; +import type { ScriptContext, ScriptRunOptions } from './script-runner.js'; + +const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000, actionTimeoutMs: 10_000 }); +const actionOpts: ScriptRunOptions = { origin: { kind: 'action', name: 'rc1_crash_probe' } }; + +function ctx(over: Partial = {}): ScriptContext { + return { input: {}, ...over }; +} + +async function faultOf(run: () => Promise): Promise { + const e = await run().then(() => null, (err) => err as SandboxError); + expect(e, 'expected the sandbox to refuse, but the script resolved').toBeInstanceOf(SandboxError); + return e!; +} + +describe('[#4431] an in-VM capability denial reaches the classifier as a FAULT', () => { + const api = { object: (_n: string) => ({ count: (_f: unknown) => 1 }) }; + + it("ctx.api.object(x).count without api.read — the issue's exact repro", async () => { + const err = await faultOf(() => + runner.runScript( + { + language: 'js', + source: "return ctx.api.object('showcase_task').count({});", + capabilities: [], // the denial under test + }, + ctx({ api }), + actionOpts, + ), + ); + + // THE fix: no `innerMessage`. `domains/actions.ts` reads its absence (plus + // the non-`Error` name) as an unexpected fault → `errorFromThrown(err, 500)`. + // With it set, the denial was served as a deliberate 400 and stayed + // invisible to gateway error rates, APM and alerting. + expect(err.innerMessage).toBeUndefined(); + + // The debug prefix must not reach the client. The runner's own doc says + // only the business message should — and a sandbox fault has none, so what + // the client sees is this text, minus the prefix. + expect(err.message).not.toContain('SandboxError:'); + // …while the actionable content survives: which capability, whose, and the + // call that tripped the gate. + expect(err.message).toContain("capability 'api.read' not granted"); + expect(err.message).toContain("action 'rc1_crash_probe'"); + expect(err.message).toContain("ctx.api.object('showcase_task').count"); + + // Nor does it get the ` '' threw:` wrapper — nothing threw; the + // sandbox refused before user code could run the call. + expect(err.message).not.toContain('threw:'); + + // No `code`/`fields` either: both are "structured domain failure" markers + // the classifier reads as a REJECTION, so carrying one would re-break the + // 500 the same way `innerMessage` did. + expect(err.code).toBeUndefined(); + expect(err.fields).toBeUndefined(); + + // The classifier's own predicate, spelled out. + expect(err.name).toBe('SandboxError'); + }); + + it('ctx.log without the log capability', async () => { + const log = { info: () => {}, warn: () => {}, error: () => {} }; + const err = await faultOf(() => + runner.runScript( + { language: 'js', source: "ctx.log.info('hi'); return 1;", capabilities: [] }, + ctx({ log }), + actionOpts, + ), + ); + expect(err.innerMessage).toBeUndefined(); + expect(err.message).not.toContain('SandboxError:'); + expect(err.message).toContain("capability 'log' not granted"); + }); + + it('ctx.crypto.randomUUID without the crypto.uuid capability', async () => { + const err = await faultOf(() => + runner.runScript( + { language: 'js', source: 'return ctx.crypto.randomUUID();', capabilities: [] }, + ctx(), + actionOpts, + ), + ); + expect(err.innerMessage).toBeUndefined(); + expect(err.message).not.toContain('SandboxError:'); + expect(err.message).toContain("capability 'crypto.uuid' not granted"); + }); + + it('ctx.api.transaction without the api.transaction capability', async () => { + const err = await faultOf(() => + runner.runScript( + { + language: 'js', + source: 'return await ctx.api.transaction(async () => 1);', + capabilities: ['api.read'], + }, + ctx({ api }), + actionOpts, + ), + ); + expect(err.innerMessage).toBeUndefined(); + expect(err.message).not.toContain('SandboxError:'); + expect(err.message).toContain("capability 'api.transaction' not granted"); + }); + + it('a denial the body CATCHES and rethrows as its own error stays a rejection', async () => { + // The other direction, and the reason the marker is a property on the VM + // error rather than a match on the flattened `SandboxError: …` text: once + // user code has caught the denial and thrown its own business error, the + // outcome IS a deliberate rejection and must keep its 400. + const err = await faultOf(() => + runner.runScript( + { + language: 'js', + source: `try { await ctx.api.object('t').count({}); } + catch (e) { throw new Error('权限不足,请联系管理员'); } + return 1;`, + capabilities: [], + }, + ctx({ api }), + actionOpts, + ), + ); + expect(err.innerMessage).toBe('权限不足,请联系管理员'); + expect(err.message).toContain("action 'rc1_crash_probe' threw:"); + }); +}); + +describe('[#4431] the rejection side of the contract is untouched', () => { + it('a deliberate throw still carries innerMessage and the debug wrapper', async () => { + const err = await faultOf(() => + runner.runScript( + { language: 'js', source: "throw new Error('线索信息不完整');", capabilities: [] }, + ctx(), + actionOpts, + ), + ); + expect(err.innerMessage).toBe('线索信息不完整'); + expect(err.message).toContain("action 'rc1_crash_probe' threw:"); + }); + + it('a structured error crossing out of ctx.api keeps its code and fields', async () => { + // The #3937/#4345 passthrough: a record ValidationError raised by a host + // call is a REJECTION, and its structure must survive. Pinned here because + // the fault marker is set on the same path (`hostErrorToVm`) — a marker + // applied too broadly would turn every failed write into a 500. + const api = { + object: (_n: string) => ({ + update: async () => { + const e: any = new Error('issued_on is required'); + e.name = 'ValidationError'; + e.code = 'VALIDATION_FAILED'; + e.fields = [{ field: 'issued_on', code: 'required', message: 'issued_on is required' }]; + throw e; + }, + }), + }; + const err = await faultOf(() => + runner.runScript( + { + language: 'js', + source: "return await ctx.api.object('inv').update('1', {});", + capabilities: ['api.write'], + }, + ctx({ api }), + actionOpts, + ), + ); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toHaveLength(1); + expect(err.innerMessage).toContain('issued_on is required'); + }); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index ae4d228d89..9f5964ca28 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -281,8 +281,8 @@ export class QuickJSScriptRunner implements ScriptRunner { `function(e){ globalThis.__error = (e && e.message) ? (e.name + ': ' + e.message) : String(e); try { - globalThis.__errorInfo = (e && (e.code || e.fields)) - ? JSON.stringify({ code: e.code, fields: e.fields }) + globalThis.__errorInfo = (e && (e.code || e.fields || e['${SANDBOX_FAULT_PROP}'])) + ? JSON.stringify({ code: e.code, fields: e.fields, sandboxFault: e['${SANDBOX_FAULT_PROP}'] === true }) : undefined; } catch (_) { globalThis.__errorInfo = undefined; } }`; @@ -379,10 +379,25 @@ export class QuickJSScriptRunner implements ScriptRunner { // "InternalError: interrupted". const budget = budgetError(pumps); if (budget) throw budget; + const info = readErrorInfo(vm); + // [#4431] A SANDBOX fault that crossed `__error` — a capability + // denial thrown synchronously inside a host function, an unavailable + // `ctx.api`, a marshalling failure. It is not an outcome the body + // chose to report, so it gets neither the ` '' threw:` + // wrapper (nothing threw — the sandbox refused) nor an + // `innerMessage` (there is no business message; `SandboxError`'s own + // contract says so). Leaving `innerMessage` unset is what makes the + // #3951 contract hold: the dispatcher's classifier reads its absence + // as a CRASH and answers 500 through `errorFromThrown`, instead of + // the 400 a denial used to get — and the client sees the capability + // text without the `SandboxError: ` debug prefix. + if (info?.sandboxFault) { + throw new SandboxError(sandboxFaultMessage(String(errStr))); + } throw new SandboxError( `${args.origin.kind} '${args.origin.name}' threw: ${errStr}`, userFacingMessage(String(errStr)), - readErrorInfo(vm), + info, ); } @@ -523,7 +538,8 @@ export class QuickJSScriptRunner implements ScriptRunner { const installTxLeaf = (name: string, run: () => Promise): void => { const fn = vm.newFunction(name, () => { if (!caps.has('api.transaction')) { - throw new SandboxError( + throwSandboxFault( + vm, `capability 'api.transaction' not granted to ${origin.kind} '${origin.name}' (called ctx.api.transaction)`, ); } @@ -591,7 +607,7 @@ export class QuickJSScriptRunner implements ScriptRunner { for (const level of ['info', 'warn', 'error'] as const) { const fn = vm.newFunction(level, (msgH, dataH) => { if (!caps.has('log')) { - throw new SandboxError(`capability 'log' not granted to ${origin.kind} '${origin.name}'`); + throwSandboxFault(vm, `capability 'log' not granted to ${origin.kind} '${origin.name}'`); } const msg = vm.getString(msgH); const data = dataH ? safeJsonParse(vm.getString(dataH)) : undefined; @@ -607,7 +623,7 @@ export class QuickJSScriptRunner implements ScriptRunner { const cryptoObj = vm.newObject(); const uuidFn = vm.newFunction('randomUUID', () => { if (!caps.has('crypto.uuid')) { - throw new SandboxError(`capability 'crypto.uuid' not granted to ${origin.kind} '${origin.name}'`); + throwSandboxFault(vm, `capability 'crypto.uuid' not granted to ${origin.kind} '${origin.name}'`); } const v = ctx.crypto?.randomUUID?.() ?? cryptoRandomUUID(); return vm.newString(v); @@ -776,13 +792,14 @@ function installApiMethod( // Capability gate — throw synchronously so the VM sees a normal exception at // the call site (mirrors ctx.log / ctx.crypto gating). if (!caps.has(required)) { - throw new SandboxError( + throwSandboxFault( + vm, `capability '${required}' not granted to ${origin.kind} '${origin.name}' (called ctx.api.object('${objectName}').${method})`, ); } const apiAny = ctx.api as Record | undefined; if (!apiAny || typeof apiAny.object !== 'function') { - throw new SandboxError(`ctx.api unavailable in ${origin.kind} '${origin.name}'`); + throwSandboxFault(vm, `ctx.api unavailable in ${origin.kind} '${origin.name}'`); } // Dump args now, while the handles are alive — they are freed when this // function returns, long before the async work below runs. @@ -901,12 +918,93 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle { vm.setProp(errH, 'fields', h); h.dispose(); } + // [#4431] Mark the sandbox's OWN faults so the pump loop can tell them + // apart from a user throw after the VM has flattened both to a string. + if (err instanceof SandboxError) { + const h = vm.true; + vm.setProp(errH, SANDBOX_FAULT_PROP, h); + } } catch { /* keep the bare name/message error */ } return errH; } +/** + * [#4431] The marker a sandbox-internal fault carries THROUGH the VM. + * + * ## The problem it solves + * + * A capability gate throws `SandboxError` **synchronously inside a QuickJS host + * function**. That rejects the async IIFE *inside* the VM, so it comes back + * through the `__error` side-channel — and the pump loop presumed that anything + * arriving there was user code throwing deliberately: + * + * ```ts + * throw new SandboxError( + * `${kind} '${name}' threw: ${errStr}`, + * userFacingMessage(String(errStr)), // ← innerMessage SET + * ); + * ``` + * + * `SandboxError`'s own contract says the opposite — `innerMessage` is + * "undefined for the sandbox's own internal errors (capability denials, + * timeouts, marshalling failures), which have no user-meaningful inner + * message" — and the #3951 crash contract pins the consequence: *"`SandboxError` + * with no `innerMessage` — timeout, **capability denial** → crash → 500"*. + * With `innerMessage` set, `domains/actions.ts`'s classifier read the denial as + * a deliberate rejection and answered **400**, leaving every capability denial + * invisible to gateway error rates, APM and alerting. The client also received + * the `SandboxError: ` debug prefix, which only ever belonged in server logs. + * + * The jsdoc's claim only held for denials detected OUTSIDE evaluation (a + * timeout, which takes the separate `budgetError` path). In-VM host-call + * denials — `ctx.api.*`, `ctx.log`, `ctx.crypto`, `ctx.api.transaction` — were + * misclassified. + * + * ## Why a marker and not the name + * + * `__error` is a FLATTENED `: ` string by design (every existing + * consumer reads it that way), and quickjs-emscripten's host-throw conversion + * copies only `name` and `message` onto the VM error — which is exactly why the + * denial's identity was lost. Matching on the `SandboxError:` text would be + * matching on a string user code can produce. The marker is a real property set + * on the VM error object and read back through the additive `__errorInfo` + * channel, so the classification survives the flattening it is meant to outlive. + */ +const SANDBOX_FAULT_PROP = '__objectstackSandboxFault'; + +/** + * [#4431] Throw a sandbox-internal fault OUT OF a host function so it reaches + * the VM carrying {@link SANDBOX_FAULT_PROP}. + * + * quickjs-emscripten's `errorToHandle` passes a thrown HANDLE through as the VM + * exception verbatim (`error instanceof Lifetime ? error : this.newError(error)`), + * and it is `newError` — the non-handle path — that drops everything but + * `name`/`message`. Building the handle here is therefore what preserves the + * marker. The handle is consumed by `QTS_Throw`, so it must NOT be disposed + * here. + * + * The host-side `SandboxError` is still constructed: it is what carries the + * message, and it keeps this helper's call sites reading like the plain + * `throw new SandboxError(...)` they replaced. + */ +function throwSandboxFault(vm: QuickJSContext, message: string): never { + throw hostErrorToVm(vm, new SandboxError(message)); +} + +/** + * Strip the `SandboxError: ` name prefix the VM's flattening prepends. + * + * The runner's own doc says only the business message should reach a client; + * for a sandbox fault there is no business message at all, so what reaches the + * client is this text — the capability, the origin and the call that tripped + * the gate — with the debug prefix removed. + */ +function sandboxFaultMessage(raw: string): string { + return raw.startsWith('SandboxError: ') ? raw.slice('SandboxError: '.length) : raw; +} + /** Marshal a host JSON-serializable value into a QuickJS handle. */ function jsonToHandle(vm: QuickJSContext, v: unknown): QuickJSHandle { const json = safeJsonStringify(v); @@ -1057,6 +1155,14 @@ export class SandboxError extends Error { export interface SandboxErrorInfo { code?: string; fields?: unknown[]; + /** + * [#4431] The error that crossed `__error` was the SANDBOX's own fault — a + * denied capability, an unavailable `ctx.api`, a marshalling failure — not + * user code rejecting. Read by the pump loop, which then leaves + * `innerMessage` undefined so the #3951 crash contract's "SandboxError with + * no innerMessage → 500" holds for in-VM host-call denials too. + */ + sandboxFault?: boolean; } /** @@ -1080,11 +1186,12 @@ function readErrorInfo(vm: QuickJSContext): SandboxErrorInfo | undefined { } catch { return undefined; } - const p = parsed as { code?: unknown; fields?: unknown }; + const p = parsed as { code?: unknown; fields?: unknown; sandboxFault?: unknown }; const info: SandboxErrorInfo = {}; if (typeof p?.code === 'string' && p.code) info.code = p.code; if (Array.isArray(p?.fields)) info.fields = p.fields; - return info.code || info.fields ? info : undefined; + if (p?.sandboxFault === true) info.sandboxFault = true; + return info.code || info.fields || info.sandboxFault ? info : undefined; } /** diff --git a/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts new file mode 100644 index 0000000000..d1312c4ee8 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4437 — the measure SOURCE-FIELD gate. + * + * `inferMeasure` maps a suffix convention onto a field name and cannot know + * whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the + * driver threw `no such column`, and the caller got a driver error class on + * the wire for what is a plain typo. Live repro on a showcase dev server + * before the fix: + * + * ``` + * POST /analytics/query {"cube":"showcase_invoice","measures":["ghost_sum"]} + * → 500 {"code":"SQLITE_ERROR","message":"Internal server error"} + * ``` + * + * A dotted spelling took the same path (`"total.sum"` → prefix-strip → + * `inferMeasure('sum')` → `SUM(sum)` → 500). The DATA route has refused the + * same mistake with a `400 INVALID_FIELD` naming the field since #4315/#4254; + * these cases pin the analytics half of that answer, and pin the tiering that + * keeps it from over-reaching (ADR-0112: a driver error class is never the + * `error.code` for a caller-shaped mistake). + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import { AnalyticsService } from '../analytics-service.js'; + +const silentLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +} as any; + +const INVOICE_FIELDS = ['id', 'total', 'status', 'issued_on', 'account']; + +/** + * A service over one object (`showcase_invoice`) whose columns are known. + * `aggregated` records every object an aggregate actually ran against, so a + * test can assert the rejected query never reached the driver. + */ +function makeService(opts: { cubes?: Cube[]; wireProbe?: boolean; fields?: string[] } = {}) { + const aggregated: string[] = []; + const service = new AnalyticsService({ + logger: silentLogger, + ...(opts.cubes ? { cubes: opts.cubes } : {}), + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (objectName: string) => { + aggregated.push(objectName); + return [{ count: 1 }]; + }, + isRegisteredObject: (n: string) => n === 'showcase_invoice', + ...(opts.wireProbe === false + ? {} + : { + getObjectFieldNames: (n: string) => + n === 'showcase_invoice' ? (opts.fields ?? INVOICE_FIELDS) : undefined, + }), + }); + return { service, aggregated }; +} + +/** The envelope the DATA route already produces for the same mistake (#4315). */ +const INVALID_FIELD = { + code: 'INVALID_FIELD', + status: 400, + object: 'showcase_invoice', + param: 'measures', +}; + +describe('#4437 — measure source-field gate', () => { + it('refuses a measure over a missing field with a 400, not a driver 500', async () => { + const { service, aggregated } = makeService(); + + await expect( + service.query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any), + ).rejects.toMatchObject({ ...INVALID_FIELD, field: 'ghost', measure: 'ghost_sum' }); + + // The whole point: the typo never became a column. + expect(aggregated).toEqual([]); + }); + + it('names the missing field in the message so the caller can act on it', async () => { + const { service } = makeService(); + + await expect( + service.query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any), + ).rejects.toThrow(/aggregates field 'ghost'/); + }); + + it('does not offer the caller their own typo back as a valid measure', async () => { + // On the auto-inference path the bogus measure is already in + // `cube.measures` (it was inferred from this very query), so echoing the + // cube's measure list verbatim suggested `ghost_sum` — the one + // alternative guaranteed not to work. + const { service } = makeService(); + + const err = await service + .query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any) + .catch((e) => e as Error); + + expect(err.message).toMatch(/Valid measures: count\./); + expect(err.message).not.toMatch(/Valid measures:[^.]*ghost_sum/); + }); + + it('refuses the dotted spelling the same way, naming what it stripped to', async () => { + // `total.sum` prefix-strips to `sum`, which infers `SUM(sum)` — a column + // named `sum` that does not exist. Same 500 pre-fix, same 400 now. + const { service, aggregated } = makeService(); + + await expect( + service.query({ cube: 'showcase_invoice', measures: ['total.sum'] } as any), + ).rejects.toMatchObject({ ...INVALID_FIELD, field: 'sum', measure: 'total.sum' }); + + expect(aggregated).toEqual([]); + }); + + it('does not poison the registry with the rejected cube', async () => { + // Same rule the #3867 inference gate keeps: a rejected query must leave + // no trace, or the retry finds a "registered" cube carrying the bogus + // measure and sails straight into SQL. + const { service, aggregated } = makeService(); + + await expect( + service.query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any), + ).rejects.toThrow(); + expect(service.cubeRegistry.get('showcase_invoice')).toBeUndefined(); + + await expect( + service.query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any), + ).rejects.toMatchObject(INVALID_FIELD); + expect(aggregated).toEqual([]); + }); + + it('lets every legitimate measure spelling through unchanged', async () => { + const { service, aggregated } = makeService(); + + // `count(*)` — the one legitimately field-less aggregate. + await service.query({ cube: 'showcase_invoice', measures: ['count'] } as any); + // A real field under each inferred suffix. + await service.query({ + cube: 'showcase_invoice', + measures: ['total_sum', 'total_avg', 'total_min', 'total_max', 'total_count_distinct'], + } as any); + // Engine-assigned columns are admitted like the data path admits them. + await service.query({ cube: 'showcase_invoice', measures: ['created_at_max'] } as any); + + expect(aggregated).toEqual(Array(3).fill('showcase_invoice')); + }); + + it('gates generateSql too, not just query', async () => { + // `/analytics/sql` runs the same `ensureCube`; leaving it ungated would + // hand back SQL naming a column that does not exist. + const { service } = makeService(); + + await expect( + service.generateSql({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any), + ).rejects.toMatchObject(INVALID_FIELD); + }); + + it('validates an AUTHORED cube whose declared measure lost its field', async () => { + // An authored cube is not second-guessed about WHICH table it reads + // (#3867), but a measure it declares over a dropped column is the same + // caller-visible 500 — and here the suggestion list is real. + const authored: Cube = { + name: 'invoice_cube', + title: 'Invoices', + sql: 'showcase_invoice', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'total' }, + legacy: { name: 'legacy', label: 'Legacy', type: 'sum', sql: 'dropped_column' }, + }, + dimensions: {}, + public: false, + }; + const { service, aggregated } = makeService({ cubes: [authored] }); + + await expect( + service.query({ cube: 'invoice_cube', measures: ['legacy'] } as any), + ).rejects.toMatchObject({ ...INVALID_FIELD, field: 'dropped_column', measure: 'legacy' }); + expect(aggregated).toEqual([]); + + // Its healthy siblings still run, and are what the rejection suggests. + await service.query({ cube: 'invoice_cube', measures: ['revenue'] } as any); + expect(aggregated).toEqual(['showcase_invoice']); + }); + + it('leaves a cube whose `sql` is an expression alone — no field list to check', async () => { + // `sql` is a subquery, not an object name: there is no schema to consult, + // and guessing would reject perfectly good authored analytics. + const derived: Cube = { + name: 'derived_cube', + title: 'Derived', + sql: 'SELECT * FROM showcase_invoice WHERE status = 1', + measures: { anything_sum: { name: 'anything_sum', label: 'x', type: 'sum', sql: 'anything' } }, + dimensions: {}, + public: false, + }; + const { service } = makeService({ cubes: [derived] }); + + await expect( + service.query({ cube: 'derived_cube', measures: ['anything_sum'] } as any), + ).resolves.toBeTruthy(); + }); + + it('leaves a dotted cross-object measure to the layers that own it', async () => { + // `account.balance` resolves through a JOIN this gate cannot see — + // `balance` is not a column of `showcase_invoice` and must not be + // reported as a missing one. Whether the query can run at all is the + // strategy's call (the ObjectQL aggregate path declines cross-object + // measures outright) and the join allowlist's (ADR-0021 D-C); either + // way the answer must not be this gate's INVALID_FIELD. + const joined: Cube = { + name: 'joined_cube', + title: 'Joined', + sql: 'showcase_invoice', + measures: { + remote_sum: { name: 'remote_sum', label: 'Remote', type: 'sum', sql: 'account.balance' }, + }, + dimensions: {}, + public: false, + }; + const { service } = makeService({ cubes: [joined] }); + + const err = await service + .query({ cube: 'joined_cube', measures: ['remote_sum'] } as any) + .catch((e) => e as Error & { code?: string }); + + expect(err).toBeInstanceOf(Error); + expect(err.code).not.toBe('INVALID_FIELD'); + // It got as far as the strategy — i.e. past this gate — and was declined + // there for the strategy's own declared reason. + expect(err.message).toMatch(/cannot evaluate a cross-object measure/); + }); + + it('stands down when no field probe is configured — nothing to consult', async () => { + // Same tiering as the #3867 registry gate and the data path's + // `resolveQueryFields`: with no source of truth the question cannot be + // answered, and failing closed would break every embedding that runs + // analytics without a data engine. + const { service, aggregated } = makeService({ wireProbe: false }); + + await service.query({ cube: 'showcase_invoice', measures: ['ghost_sum'] } as any); + + expect(aggregated).toEqual(['showcase_invoice']); + }); + + it('stands down for an object the probe cannot describe', async () => { + // An external datasource whose columns are not mirrored locally answers + // `undefined` — "cannot answer", not "has no fields". + const external: Cube = { + name: 'external_cube', + title: 'External', + sql: 'remote_table', + measures: { ghost_sum: { name: 'ghost_sum', label: 'x', type: 'sum', sql: 'ghost' } }, + dimensions: {}, + public: false, + }; + const { service } = makeService({ cubes: [external] }); + + await expect( + service.query({ cube: 'external_cube', measures: ['ghost_sum'] } as any), + ).resolves.toBeTruthy(); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index c88beb60e3..7f8d1a9c8e 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -92,6 +92,14 @@ function isMissingSourceError(err: unknown): boolean { ); } +/** + * [#4437] A name that is a plain column/table identifier and nothing else. + * Anything with a dot, a paren, whitespace or an operator is a SQL EXPRESSION + * (or a cross-object reference) whose parts this layer cannot attribute to a + * single field — such measures pass the source-field gate untouched. + */ +const BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i; + /** * Configuration for AnalyticsService. */ @@ -209,6 +217,26 @@ export interface AnalyticsServiceConfig { * always wires it. */ isRegisteredObject?: (name: string) => boolean; + /** + * [#4437] The FIELD NAMES `objectName` declares, or `undefined` when nothing + * authoritative can answer. + * + * Consulted by {@link AnalyticsService.ensureCube} to validate the SOURCE + * FIELD a measure resolves to BEFORE any SQL is built. `inferMeasure` maps a + * suffix convention onto a field name (`ghost_sum` → `SUM(ghost)`) and used + * to accept any spelling, so a typo'd measure reached the driver as a column + * and came back as an opaque `500 SQLITE_ERROR` — a driver error class on the + * wire for a caller-shaped mistake (ADR-0112). The DATA route already refuses + * the same mistake with a `400 INVALID_FIELD` naming the field (#4315/#4254); + * this hook is what lets the ANALYTICS route give the same answer. + * + * Same tiering as {@link isRegisteredObject}: absence means "skip the check" + * (registry-less hosts, engine doubles, external datasources whose columns + * are not mirrored locally). The production bridge in `plugin.ts` wires it + * from the same schema registry the data path's gate reads, so "which fields + * exist" has ONE answer across `/data` and `/analytics`. + */ + getObjectFieldNames?: (objectName: string) => readonly string[] | undefined; /** * ADR-0021 — optional object-graph resolver used when compiling datasets: * `(baseObject, relationshipName) => relatedObjectName | undefined`. When @@ -300,6 +328,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly draftRowsResolver?: AnalyticsServiceConfig['draftRowsResolver']; /** [#3867] Schema-registry probe gating cube auto-inference. */ private readonly isRegisteredObject?: AnalyticsServiceConfig['isRegisteredObject']; + /** [#4437] Field-name probe gating measure source-field resolution. */ + private readonly getObjectFieldNames?: AnalyticsServiceConfig['getObjectFieldNames']; /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */ private warnedNoObjectRegistry = false; readonly cubeRegistry: CubeRegistry; @@ -320,6 +350,7 @@ export class AnalyticsService implements IAnalyticsService { this.labelResolver = config.labelResolver; this.draftRowsResolver = config.draftRowsResolver; this.isRegisteredObject = config.isRegisteredObject; + this.getObjectFieldNames = config.getObjectFieldNames; // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { @@ -860,6 +891,11 @@ export class AnalyticsService implements IAnalyticsService { // such check: it was authored, and its `sql` is whatever it declares. this.assertInferableCube(name); cube = this.inferCubeFromQuery(query); + // [#4437] Validate the inferred measures' SOURCE FIELDS before the cube + // is registered — a rejected query must leave no trace in the registry + // (same rule the #3867 gate above keeps), or a retry would find a + // "registered" cube carrying the bogus measure and sail straight to SQL. + this.assertMeasureFields(query, cube, Object.keys(cube.measures)); this.cubeRegistry.register(cube); // A scalar query — only measures, no grouping (no `dimensions`/ // `timeDimensions`) — is the first-class "metric over an object" path @@ -894,10 +930,107 @@ export class AnalyticsService implements IAnalyticsService { ...cube, measures: { ...cube.measures, ...extraMeasures }, }; + // [#4437] The cube's DECLARED measures are the ones a caller may name; + // the suffix-inferred entries just added are a convenience, not a + // vocabulary. Snapshot the declared list BEFORE registering the augmented + // cube so the rejection can suggest what the caller could have meant — + // and so a rejected query leaves the registry as it found it. + this.assertMeasureFields(query, augmented, Object.keys(cube.measures)); this.cubeRegistry.register(augmented); this.logger.debug( `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(',')}`, ); + } else { + // No inference happened — every measure is declared. Still validate: an + // authored cube can declare a measure over a field the object dropped. + this.assertMeasureFields(query, cube, Object.keys(cube.measures)); + } + } + + /** + * [#4437] Reject a measure whose SOURCE FIELD the backing object does not + * have, BEFORE the strategy compiles it into SQL. + * + * `inferMeasure` maps a suffix convention onto a field name and has no way to + * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the + * driver threw `no such column`, and the caller got + * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver + * error class on the wire, and nothing actionable, for what is a plain typo. + * The DATA route has refused the same mistake with a `400 INVALID_FIELD` + * naming the field since #4315/#4254; this is the analytics half of that + * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/ + * `param`) so one mistake has one shape across both routes. + * + * What it checks, and what it deliberately does not: + * + * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose + * `sql` is a real SQL expression has no field list to check against. + * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers. + * Absent hook / unknown object → stand down (see the config field's doc). + * - Only measures whose source is a BARE COLUMN. `count(*)` has no source + * field, and a dotted reference (`account.industry`) resolves through a + * join whose target this check cannot see — both pass through untouched. + * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching + * the data path's `resolveQueryFields`: they are engine-assigned rather than + * declared, and a gate stricter than the engine it guards would reject + * queries that used to work. + */ + private assertMeasureFields(query: AnalyticsQuery, cube: Cube, declaredMeasures: string[]): void { + const probe = this.getObjectFieldNames; + if (!probe) return; + const measures = query.measures ?? []; + if (measures.length === 0) return; + + const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!object || !BARE_IDENTIFIER.test(object)) return; + const fieldNames = probe(object); + if (!fieldNames || fieldNames.length === 0) return; + const known = new Set([...fieldNames, 'id', 'created_at', 'updated_at']); + + const stripPrefix = (m: string) => (m.includes('.') ? m.split('.').slice(1).join('.') : m); + /** The source field a measure aggregates, or null when there is nothing to check. */ + const sourceFieldOf = (measure: string): string | null => { + const metric = cube.measures[stripPrefix(measure)] as { type?: string; sql?: unknown } | undefined; + if (!metric) return null; + // `count(*)` is the one legitimately field-less aggregate. + if (metric.type === 'count' && (metric.sql === '*' || metric.sql == null)) return null; + const source = typeof metric.sql === 'string' ? metric.sql.trim() : ''; + if (!source || source === '*' || !BARE_IDENTIFIER.test(source)) return null; + return source; + }; + + // Two passes so the rejection can suggest the measures that WOULD have + // worked. On the auto-inference path `cube.measures` already carries the + // caller's own bogus spelling (it was inferred from the query moments ago), + // so echoing the cube's measure list verbatim would offer the typo back as + // a valid alternative — the one suggestion guaranteed to be wrong. + const invalid = new Set(); + for (const measure of measures) { + const source = sourceFieldOf(measure); + if (source && !known.has(source)) invalid.add(stripPrefix(measure)); + } + if (invalid.size === 0) return; + const usable = declaredMeasures.filter((m) => !invalid.has(m)); + + for (const measure of measures) { + const source = sourceFieldOf(measure); + if (!source || known.has(source)) continue; + + const err = new Error( + `Measure '${measure}' on cube '${cube.name}' aggregates field '${source}', which object ` + + `'${object}' does not have. ` + + `Valid measures: ${usable.join(', ') || '(none)'}. ` + + `Other measures are inferred from the object's OWN fields as ` + + `'_sum' / '_avg' / '_min' / '_max' / '_count_distinct', so check the spelling of ` + + `'${source}' — known fields: ${[...fieldNames].sort().join(', ')}.`, + ) as Error & { code?: string; status?: number; field?: string; object?: string; param?: string; measure?: string }; + err.code = 'INVALID_FIELD'; + err.status = 400; + err.field = source; + err.object = object; + err.param = 'measures'; + err.measure = measure; + throw err; } } diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 1d62ad0ded..256b723b25 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -548,6 +548,18 @@ export class AnalyticsServicePlugin implements Plugin { if (!engine) return true; return engine.getObject?.(name) != null; }, + // [#4437] Field names for the measure source-field gate. Read from the + // SAME schema registry `isRegisteredObject` above consults (and the data + // path's #4315 gate reads), so "which fields exist" has one answer across + // /data and /analytics. `undefined` — no engine, unknown object, or an + // object with no field map (an external datasource whose columns are not + // mirrored locally) — means "cannot answer", and the gate stands down. + getObjectFieldNames: (objectName: string) => { + const fields = dataEngine()?.getObject?.(objectName)?.fields; + if (!fields || typeof fields !== 'object') return undefined; + const names = Object.keys(fields); + return names.length > 0 ? names : undefined; + }, draftRowsResolver, }; diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index ea143c31ee..41dfd1f9cc 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -31,228 +31,156 @@ const stack = defineStack({ ## Flow Types -ObjectStack supports three types of flows: +`type` declares how a flow starts: -### 1. Autolaunched Flows -Triggered automatically by record changes: +| `type` | Starts when | +|:---|:---| +| `record_change` | a record is created / updated / deleted — bound on the `start` node | +| `schedule` | a cron schedule fires | +| `screen` | a user runs it interactively and supplies input | +| `autolaunched` | another flow, an action or an API call invokes it | +| `api` | it is exposed as an API-callable flow | -```typescript -const autoFlow = defineFlow({ - name: 'welcome_email', - type: 'autolaunched', - trigger: { - object: 'user', - when: 'after_insert', - }, - steps: [ - { - type: 'action', - action: 'send_email', - inputs: { - to: '{!trigger.record.email}', - subject: 'Welcome to ObjectStack!', - body: 'Hello {!trigger.record.name}...', - }, - }, - ], -}); -``` +## Flow Structure + +A flow is a **directed graph**: a flat list of `nodes` joined by a flat list of +`edges`. Nodes never contain child steps — branching, looping and error paths are +all expressed as edges between top-level nodes. -### 2. Screen Flows -Interactive flows with user input: +The record-change binding lives on the `start` node's `config` +(`{ objectName, triggerType, condition }`), not at the flow top level. ```typescript -const screenFlow = defineFlow({ - name: 'create_opportunity', - type: 'screen', - steps: [ +const escalateCase = { + name: 'escalate_high_priority_case', + label: 'Escalate High Priority Case', + type: 'record_change', + version: 1, + status: 'active', + + nodes: [ { - type: 'screen', - fields: [ - { name: 'account_id', label: 'Account', type: 'lookup', object: 'account' }, - { name: 'amount', label: 'Amount', type: 'currency' }, - { name: 'close_date', label: 'Close Date', type: 'date' }, - ], + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'crm_case', + triggerType: 'record-after-write', // created OR updated + }, }, { - type: 'record_create', - object: 'opportunity', - fields: { - account_id: '{!screen.account_id}', - amount: '{!screen.amount}', - close_date: '{!screen.close_date}', - stage: 'prospecting', + id: 'check_priority', + type: 'decision', + label: 'Is High Priority?', + // Conditions are bare CEL — no braces. Each `label` MUST match an + // out-edge's `label` exactly, or the branch cannot route (#4414). + config: { + conditions: [ + { label: 'High', expression: "record.priority == 'high'" }, + { label: 'Otherwise', expression: 'true' }, + ], }, }, - ], -}); -``` - -### 3. Scheduled Flows -Run on a schedule (cron syntax): - -```typescript -const scheduledFlow = defineFlow({ - name: 'daily_report', - type: 'scheduled', - schedule: '0 9 * * *', // Every day at 9 AM - steps: [ { - type: 'query', - object: 'order', - filters: [ - { field: 'created_at', operator: 'yesterday' }, - ], - output: 'orders', + id: 'load_owner', + type: 'get_record', + label: 'Load Owner', + config: { + objectName: 'sys_user', + filter: { id: '{record.owner_id}' }, + fields: ['id', 'name', 'email'], + outputVariable: 'owner', + }, }, { - type: 'action', - action: 'send_email', - inputs: { - to: 'admin@company.com', - subject: 'Daily Orders Report', - body: 'Total orders: {!orders.length}', + id: 'flag_case', + type: 'update_record', + label: 'Flag Case', + config: { + objectName: 'crm_case', + filter: { id: '{record.id}' }, + // Field values interpolate — braces required. + fields: { escalated: true, escalation_note: 'Escalated to {owner.name}' }, }, }, + { id: 'end', type: 'end', label: 'End' }, ], -}); -``` - -## Flow Steps - -### Record Operations - -```typescript -// Create record -{ - type: 'record_create', - object: 'contact', - fields: { - name: '{!input.name}', - email: '{!input.email}', - }, - output: 'new_contact', -} - -// Update record -{ - type: 'record_update', - object: 'account', - recordId: '{!trigger.recordId}', - fields: { - status: 'active', - }, -} -// Delete record -{ - type: 'record_delete', - object: 'task', - recordId: '{!input.taskId}', -} + edges: [ + { id: 'e1', source: 'start', target: 'check_priority', type: 'default' }, + // Branching is an edge, not a nested step list. A decision routes by + // matching its branch `label` to an out-edge `label`. + { id: 'e2', source: 'check_priority', target: 'load_owner', label: 'High', type: 'conditional' }, + { id: 'e3', source: 'check_priority', target: 'end', label: 'Otherwise', type: 'conditional' }, + { id: 'e4', source: 'load_owner', target: 'flag_case', type: 'default' }, + { id: 'e5', source: 'flag_case', target: 'end', type: 'default' }, + ], +}; ``` -### Query Step +## Node Types -```typescript -{ - type: 'query', - object: 'opportunity', - filters: [ - { field: 'account_id', operator: 'eq', value: '{!trigger.record.account_id}' }, - { field: 'stage', operator: 'eq', value: 'closed_won' }, - ], - sort: [{ field: 'amount', direction: 'desc' }], - limit: 10, - output: 'opportunities', -} -``` +The built-in node type ids (`FLOW_BUILTIN_NODE_TYPES`, from `FlowNodeAction` in +`@objectstack/spec`): -### Decision (Conditional) Step +`start` · `end` · `decision` · `assignment` · `loop` · `create_record` · +`update_record` · `delete_record` · `get_record` · `http` · `notify` · `script` · +`screen` · `wait` · `subflow` · `map` · `connector_action` · `parallel_gateway` · +`join_gateway` · `boundary_event` -```typescript -{ - type: 'decision', - conditions: [ - { - label: 'High Value', - expression: '{!trigger.record.amount} > 10000', - steps: [ - { type: 'action', action: 'notify_sales_manager' }, - ], - }, - { - label: 'Medium Value', - expression: '{!trigger.record.amount} > 1000', - steps: [ - { type: 'action', action: 'assign_to_sales_rep' }, - ], - }, - ], - defaultSteps: [ - { type: 'action', action: 'auto_approve' }, - ], -} -``` +`type` is validated against the **live action registry** at `registerFlow()`, not +against a closed enum, so plugin-registered node types are equally legal. -### Loop Step +The registry is also why `FlowNodeAction` is not the whole list: the ADR-0031 +structured constructs **`parallel`** and **`try_catch`** ship built-in executors +(`builtin/parallel-node.ts`, `builtin/try-catch-node.ts`) without appearing in +that enum. See [Advanced Features](#advanced-features) below. -```typescript -{ - type: 'loop', - collection: '{!query_results}', - variable: 'item', - steps: [ - { - type: 'record_update', - object: 'task', - recordId: '{!item.id}', - fields: { - status: 'completed', - }, - }, - ], -} -``` +The CRUD quartet's `config` — the shape most often written from memory, and the +one this README used to get wrong: -### Custom Action Step +| Node | `config` keys | +|:---|:---| +| `get_record` | `objectName`, `filter`, `fields`, `limit`, `outputVariable` | +| `create_record` | `objectName`, `fields`, `outputVariable` | +| `update_record` | `objectName`, `filter`, `fields` — **no** `outputVariable`; the executor does not read one | +| `delete_record` | `objectName`, `filter` | -```typescript -{ - type: 'action', - action: 'calculate_tax', - inputs: { - amount: '{!opportunity.amount}', - region: '{!account.billing_region}', - }, - output: 'tax_amount', -} -``` +`filter` is an **object** of field/value pairs (`{ id: '{record.id}' }`), not an +array of `{ field, operator, value }` triples; operator objects such as +`{ "$ne": null }` are legal values. There is no `recordId` key — select by id +through `filter`. Unknown keys are rejected at `registerFlow()`. -## Variable Expressions +For every other node's `config`, and for loops, parallel blocks, subflows, waits +and error handling, see the maintained reference — **[Flows](/content/docs/automation/flows.mdx)**. +This README deliberately does not keep a second copy of that per-node reference. -Access variables in flow steps using `{!variable.path}` syntax: +## Expressions -```typescript -// Trigger record fields -'{!trigger.record.name}' -'{!trigger.record.account.industry}' +A flow mixes **two dialects**, and the rule is short: **every condition is CEL; +braces are for values.** -// Screen input -'{!screen.fieldName}' +| Where | Dialect | Write it like | +|:---|:---|:---| +| Start-node `condition` | CEL — bare, no braces | `record.amount > 500` | +| Edge `condition` | CEL — bare, no braces | `record.status == 'open'` | +| Decision `conditions[].expression` | CEL — bare, no braces | `order_amount > 10000` | +| Field values in `create_record` / `update_record` | Interpolation — braces required | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | -// Query results -'{!query_results[0].name}' -'{!query_results.length}' +Value bindings: `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, +`{TODAY()}`, `{TODAY() + 90}`. -// Step outputs -'{!step_name.output_field}' +The two failure modes to memorize: -// System variables -'{!now}' -'{!today}' -'{!currentUser.id}' -``` +1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the + literal text into the field. Write `'{TODAY() + 7}'`. +2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Since #4336 + conditions reject this loudly: `registerFlow()` / `objectstack validate` + refuse the flow with a CEL error naming the reference. Before that they were + compared as text and were silently always-true or always-false. + +There is no `{!…}` dialect. That is Salesforce syntax; the platform has never +parsed it. ## Service API @@ -326,46 +254,64 @@ POST /api/v1/automation/triggers/:name # Trigger a flow ## Advanced Features -### Parallel Execution +`loop`, `parallel` and `try_catch` are **structured control-flow constructs** +(ADR-0031). Each owns its body as a single-entry/single-exit **region** carried in +`config` — a nested `{ nodes, edges }` sub-graph, *not* a `steps` array — so the +outer graph stays acyclic. A region runs in the enclosing variable scope; the +container's ordinary out-edges are the continuation. + +### Loop ```typescript -const flow = defineFlow({ - name: 'parallel_processing', - steps: [ - { - type: 'parallel', - branches: [ - { - name: 'branch1', - steps: [{ type: 'action', action: 'process_a' }], - }, - { - name: 'branch2', - steps: [{ type: 'action', action: 'process_b' }], - }, - ], +{ + id: 'notify_each', + type: 'loop', + label: 'For each task', + config: { + collection: '{tasks}', // template/variable resolving to an array + iteratorVariable: 'task', // current item, visible inside the body + indexVariable: 'i', // optional zero-based index + maxIterations: 500, // hard cap (clamped to the engine ceiling) + body: { + nodes: [{ id: 'send', type: 'notify', label: 'Notify', config: { /* … */ } }], + edges: [], }, - ], -}); + }, +} +``` + +### Parallel Execution + +Branches run concurrently and join implicitly when all complete — there is no +author-visible split/join gateway. + +```typescript +{ + id: 'fan_out', + type: 'parallel', + label: 'Notify in parallel', + config: { + branches: [ // ≥ 2 regions + { name: 'Email', nodes: [{ id: 'email', type: 'notify', label: 'Email', config: { /* … */ } }], edges: [] }, + { name: 'Slack', nodes: [{ id: 'slack', type: 'notify', label: 'Slack', config: { /* … */ } }], edges: [] }, + ], + }, +} ``` ### Error Handling ```typescript { + id: 'guarded', type: 'try_catch', - trySteps: [ - { type: 'action', action: 'risky_operation' }, - ], - catchSteps: [ - { - type: 'action', - action: 'send_error_notification', - inputs: { - error: '{!error.message}', - }, - }, - ], + label: 'Charge with fallback', + config: { + try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] }, + catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] }, + errorVariable: '$error', + retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 }, + }, } ``` @@ -373,28 +319,40 @@ const flow = defineFlow({ ```typescript { + id: 'validate', type: 'subflow', - flowName: 'validate_address', - inputs: { - street: '{!input.street}', - city: '{!input.city}', + label: 'Validate Address', + config: { + flowName: 'validate_address', + input: { street: '{input.street}', city: '{input.city}' }, + outputVariable: 'validated_address', }, - output: 'validated_address', } ``` -### Wait Step +### Wait + +`wait` suspends the run durably. Its contract is the node-level +`waitEventConfig` block — **not** `config`: ```typescript { + id: 'hold', type: 'wait', - duration: { hours: 24 }, - nextSteps: [ - { type: 'action', action: 'send_reminder' }, - ], + label: 'Wait 24h', + waitEventConfig: { + eventType: 'timer', // 'timer' | 'signal' | 'webhook' | 'manual' | 'condition' + timerDuration: 'PT24H', // ISO 8601 duration + }, } ``` +The node resumes down its ordinary out-edges; there is no `nextSteps` key. + +> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the +> protocol as the **interop** representation and map onto these constructs on +> import/export — they are not the native authoring model. + ## Best Practices 1. **Keep Flows Simple**: Break complex logic into multiple flows diff --git a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts index 67a42ef97b..4f897fe49a 100644 --- a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts +++ b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts @@ -28,6 +28,7 @@ import { describe, it, expect } from 'vitest'; import { FLOW_NODE_EXPRESSION_PATHS, + getSchemalessNodeConfigJsonSchemas, resolveFlowNodeExpressions, type FlowNodeExpressionRole, } from '@objectstack/spec/automation'; @@ -93,29 +94,67 @@ function collectExpressionProps( const engine = new AutomationEngine(silentLogger()); installBuiltinNodes(engine, ctx()); -/** Every declared expression slot, derived from the live descriptors. */ -function declaredFromDescriptors(): { nodeType: string; path: string; role: FlowNodeExpressionRole }[] { - const found: { nodeType: string; path: string; role: FlowNodeExpressionRole }[] = []; +type DeclaredSlot = { nodeType: string; path: string; role: FlowNodeExpressionRole }; + +/** Resolve an `xExpression` marker to its ledger role, failing loudly on an unknown one. */ +function roleOf(nodeType: string, path: string, marker: string): FlowNodeExpressionRole { + const role = ROLE_BY_MARKER[marker]; + expect( + role, + `${nodeType}.${path} declares an unknown xExpression marker '${marker}' — ` + + `add it to ROLE_BY_MARKER and teach the validators which dialect it takes`, + ).toBeDefined(); + return role!; +} + +/** Declared expression slots on builtins that publish a descriptor `configSchema`. */ +function declaredFromDescriptors(): DeclaredSlot[] { + const found: DeclaredSlot[] = []; for (const descriptor of engine.getActionDescriptors()) { const schema = descriptor.configSchema as SchemaNode | undefined; for (const { path, marker } of collectExpressionProps(schema)) { - const role = ROLE_BY_MARKER[marker]; - expect( - role, - `${descriptor.type}.${path} declares an unknown xExpression marker '${marker}' — ` + - `add it to ROLE_BY_MARKER and teach the validators which dialect it takes`, - ).toBeDefined(); - found.push({ nodeType: descriptor.type, path, role: role! }); + found.push({ nodeType: descriptor.type, path, role: roleOf(descriptor.type, path, marker) }); + } + } + return found; +} + +/** + * Declared expression slots on the builtins that publish NO descriptor + * `configSchema` (#4439). + * + * `script` / `subflow` / `decision` keep their contract in + * `schemaless-node-config.zod.ts` on purpose, so deriving only from descriptors + * made their expression slots structurally unreachable by this ratchet — and + * because the reverse direction fails on a ledger entry nothing declares, they + * could not be entered by hand either. `decision.conditions[].expression` sat + * in that hole. + * + * Spec hands these over as JSON Schema — the same shape a descriptor's + * `configSchema` is — so the marker walk below is literally the same function. + * No second notion of "a declared expression property", which is the + * duplication a ledger exists to remove. + */ +function declaredFromSchemalessConfigs(): DeclaredSlot[] { + const found: DeclaredSlot[] = []; + for (const [nodeType, json] of Object.entries(getSchemalessNodeConfigJsonSchemas())) { + for (const { path, marker } of collectExpressionProps(json as SchemaNode)) { + found.push({ nodeType, path, role: roleOf(nodeType, path, marker) }); } } return found; } +/** Every declared expression slot, from BOTH declaration channels. */ +function declaredEverywhere(): DeclaredSlot[] { + return [...declaredFromDescriptors(), ...declaredFromSchemalessConfigs()]; +} + const key = (e: { nodeType: string; path: string; role: string }) => `${e.nodeType}.${e.path} (${e.role})`; describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { it('every xExpression property a builtin declares is in the ledger', () => { - const declared = declaredFromDescriptors(); + const declared = declaredEverywhere(); // Sanity: if this ever empties, the derivation broke and the whole ratchet // would pass vacuously — the failure mode a ledger test must not have. expect(declared.length, 'no xExpression properties found — derivation is broken').toBeGreaterThan(0); @@ -129,13 +168,39 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { ).toEqual([]); }); + // Each channel must be non-empty on its own. Merging them into one list would + // let a broken derivation on either side hide behind the other's results — + // which is exactly how the schemaless channel went unnoticed until #4439. + it.each([ + ['descriptor configSchema', declaredFromDescriptors], + ['schemaless-node-config.zod.ts', declaredFromSchemalessConfigs], + ] as const)('derives at least one slot from the %s channel', (_channel, derive) => { + expect(derive().length).toBeGreaterThan(0); + }); + it('the ledger carries no path a builtin no longer declares', () => { - const declared = new Set(declaredFromDescriptors().map(key)); + const declared = new Set(declaredEverywhere().map(key)); // Structural predicate surfaces (`config.condition`, `edge.condition`) are - // not descriptor properties and are deliberately absent from the ledger, so - // every ledger entry must correspond to a real declared property. + // not declared config properties on either channel and are deliberately + // absent from the ledger, so every ledger entry must correspond to a real + // declared property. const stale = FLOW_NODE_EXPRESSION_PATHS.map(key).filter((k) => !declared.has(k)); - expect(stale, 'stale ledger entries — the descriptor no longer declares these').toEqual([]); + expect(stale, 'stale ledger entries — no descriptor or schemaless schema declares these').toEqual([]); + }); + + it('decision.conditions[].expression is covered — the #4439 hole', () => { + const decision = FLOW_NODE_EXPRESSION_PATHS.find( + (e) => e.nodeType === 'decision' && e.path === 'conditions[].expression', + ); + expect( + decision, + 'the slot a schemaless node could not own: declared bare CEL, walked by neither validator', + ).toBeDefined(); + expect(decision!.role).toBe('predicate'); + // And it must reach the ledger through the schemaless channel specifically — + // `decision` publishes no descriptor configSchema, by design. + expect(declaredFromSchemalessConfigs().map(key)).toContain(key(decision!)); + expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!)); }); it('screen.fields[].visibleWhen is covered — the #3528 regression', () => { @@ -185,7 +250,25 @@ describe('resolveFlowNodeExpressions — path resolution (#4027)', () => { expect(resolveFlowNodeExpressions('screen', { fields: 'nope' })).toEqual([]); }); + it('resolves each decision branch predicate, with its index (#4439)', () => { + const found = resolveFlowNodeExpressions('decision', { + conditions: [ + { label: 'Yes', expression: "lead.status == 'converted'" }, + { label: 'No', expression: 'true' }, + ], + }); + expect(found.map((f) => f.path)).toEqual([ + 'conditions[0].expression', + 'conditions[1].expression', + ]); + expect(found.every((f) => f.entry.role === 'predicate')).toBe(true); + }); + it('returns nothing for a node type with no declared slots', () => { + // `config.condition` is a STRUCTURAL surface both validators already walk, + // deliberately not a ledger entry — and `assignment` declares no slots. + expect(resolveFlowNodeExpressions('assignment', { condition: 'a == b' })).toEqual([]); + // A decision branching purely on its edges declares no predicate here. expect(resolveFlowNodeExpressions('decision', { condition: 'a == b' })).toEqual([]); }); }); diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts index 0b7bc74316..bacac967f0 100644 --- a/packages/services/service-automation/src/builtin/config-parse.test.ts +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -19,6 +19,11 @@ * resolved to its value's real type; * - the deliberate exemption: a legacy flat-graph `loop` (no `config.body`) * predates the ADR-0031 construct and is not parsed. + * + * #4343 added the two schemaless nodes whose contracts could carry the same + * seam: `subflow` (flat all along — it just had a hand-written guard) and + * `script`, once retiring its non-functional dispatch branches left it flat. + * `decision` stays out: its one key is optional, so a parse would check nothing. */ import { describe, it, expect } from 'vitest'; @@ -204,4 +209,87 @@ describe('execute-time config parse (#4277)', () => { expect(result.error).toContain('map'); expect(result.error).toContain('config.collection'); }); + + // ── the two schemaless nodes that joined the seam in #4343 ────────────── + // + // `script` could not be parsed while its legal key set depended on + // `actionType`; converging it to a function call (retiring the branches that + // never delivered anything) is what made a flat parse fit. `subflow` was + // always flat — it just carried a hand-written guard instead of the contract. + + it('script refuses a node that names no callable', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('script', {})); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('does not satisfy the script contract'); + expect(result.error).toContain('config.function'); + }); + + it('script refuses a retired email stub — it used to log a line and report success', async () => { + const engine = engineWith(); + // `registerFlow` strips the retired keys on rehydration (#3903), so what + // reaches the parse is a node with nothing to run. Before #4343 this was a + // green step that delivered no mail. + engine.registerFlow('f', flowWith('script', { + actionType: 'email', template: 'task_done', recipients: ['{record.owner}'], + })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('does not satisfy the script contract'); + }); + + it('a script parse refusal is a guard — a fault edge does NOT route it', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith( + 'script', + { actionType: 'slack', template: 't' }, + { + nodes: [{ id: 'recover', type: 'assignment', label: 'R', config: { recovered: true } }], + edges: [{ id: 'e3', source: 'n1', target: 'recover', type: 'fault' }], + }, + )); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('does not satisfy the script contract'); + }); + + it('an unresolvable function name stays ROUTABLE — the registry is the host, not the metadata', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith( + 'script', + { function: 'never_registered' }, + { + nodes: [{ id: 'recover', type: 'assignment', label: 'R', config: { recovered: true } }], + edges: [{ id: 'e3', source: 'n1', target: 'recover', type: 'fault' }], + }, + )); + + // The negative half of the contract (#3863): the same flow succeeds on a + // host that registers the name, so the author must be able to handle it. + const result = await engine.execute('f'); + expect(result.success).toBe(true); + }); + + it('subflow refuses a missing flowName through the contract, not a hand-written check', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('subflow', {})); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('does not satisfy the subflow contract'); + expect(result.error).toContain('config.flowName'); + }); + + it('subflow refuses an empty flowName — declared is not the same as named', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('subflow', { flowName: '' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('config.flowName'); + }); }); diff --git a/packages/services/service-automation/src/builtin/decision-branch-routing.test.ts b/packages/services/service-automation/src/builtin/decision-branch-routing.test.ts new file mode 100644 index 0000000000..426e089967 --- /dev/null +++ b/packages/services/service-automation/src/builtin/decision-branch-routing.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { registerLogicNodes } from './logic-nodes.js'; + +/** + * #4414 — a decision node declared three ways to route a branch and two of them + * did nothing. + * + * • `decision.config.conditions[].label` → `branchLabel` matched no out-edge + * label anywhere in the repo and fell back to the full edge set, silently. + * • `FlowEdgeSchema.isDefault` had ZERO readers: it parsed, it was documented + * as "the default path when no other conditions match", and it routed + * nothing. + * • `edge.condition` was the only one that worked. + * + * The consequence shipped in `examples/app-crm` — see the first block below. + */ + +const warnings: string[] = []; + +function createTestLogger(): any { + return { + info: () => {}, + warn: (msg: string) => { warnings.push(String(msg)); }, + error: () => {}, + debug: () => {}, + child: () => createTestLogger(), + }; +} + +function createCtx(): any { + return { logger: createTestLogger(), getService: () => undefined }; +} + +describe('decision branch routing (#4414)', () => { + let engine: AutomationEngine; + let visited: string[]; + + beforeEach(() => { + warnings.length = 0; + visited = []; + engine = new AutomationEngine(createTestLogger()); + registerLogicNodes(engine, createCtx()); + // A do-nothing terminal so a visited branch is observable without + // dragging screen/CRUD executors into the test. + engine.registerNodeExecutor({ + type: 'mark', + async execute(node) { + visited.push(node.id); + return { success: true }; + }, + }); + }); + + /** The guard from `examples/app-crm/src/flows/convert-lead.flow.ts`. */ + function guardFlow(opts: { + conditions?: Array<{ label: string; expression: string }>; + proceedIsDefault?: boolean; + }) { + return { + name: 'guard', + label: 'Guard', + type: 'autolaunched' as const, + variables: [{ name: 'lead', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { + id: 'check', type: 'decision' as const, label: 'Already converted?', + ...(opts.conditions ? { config: { conditions: opts.conditions } } : {}), + }, + { id: 'abort', type: 'mark' as const, label: 'Abort' }, + { id: 'proceed', type: 'mark' as const, label: 'Proceed' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { + id: 'e_yes', source: 'check', target: 'abort', label: 'Yes', + condition: "lead.status == 'converted'", + }, + { + id: 'e_no', source: 'check', target: 'proceed', label: 'No', + ...(opts.proceedIsDefault ? { isDefault: true } : {}), + }, + ], + }; + } + + const run = (lead: Record) => + engine.execute('guard', { params: { lead } } as any); + + // ── The shipped defect, and its fix ─────────────────────────────────── + + it('runs BOTH branches when the fallback edge is merely unconditional', async () => { + engine.registerFlow('guard', guardFlow({})); + await run({ status: 'converted' }); + // This is the bug as reported: the abort screen AND the wizard behind it. + expect(visited).toEqual(['abort', 'proceed']); + }); + + it('takes exactly one branch once the fallback edge is `isDefault`', async () => { + engine.registerFlow('guard', guardFlow({ proceedIsDefault: true })); + + await run({ status: 'converted' }); + expect(visited).toEqual(['abort']); + + visited.length = 0; + await run({ status: 'open' }); + expect(visited).toEqual(['proceed']); + }); + + // ── `isDefault` — BPMN default flow ─────────────────────────────────── + + it('records a `skipped` step for a default edge passed over by a real branch', async () => { + engine.registerFlow('guard', guardFlow({ proceedIsDefault: true })); + await run({ status: 'converted' }); + const [log] = await engine.listRuns('guard'); + const skipped = log!.steps!.find((s) => s.nodeId === 'proceed'); + expect(skipped?.status).toBe('skipped'); + expect(skipped?.skippedBy).toMatchObject({ nodeId: 'check', edgeId: 'e_no' }); + }); + + it('keeps a default edge out of the unconditional parallel fan-out', async () => { + // Two plain unconditional edges still fan out; the default one does not + // join them when a conditional sibling matched. + engine.registerFlow('fanout', { + name: 'fanout', + label: 'Fanout', + type: 'autolaunched', + variables: [{ name: 'lead', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'check', type: 'decision', label: 'Check' }, + { id: 'hit', type: 'mark', label: 'Hit' }, + { id: 'always', type: 'mark', label: 'Always' }, + { id: 'otherwise', type: 'mark', label: 'Otherwise' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'hit', condition: "lead.status == 'converted'" }, + { id: 'e3', source: 'check', target: 'always' }, + { id: 'e4', source: 'check', target: 'otherwise', isDefault: true }, + ], + }); + await engine.execute('fanout', { params: { lead: { status: 'converted' } } } as any); + expect(visited).toContain('hit'); + expect(visited).toContain('always'); + expect(visited).not.toContain('otherwise'); + }); + + it('takes the default edge when the node has no conditional siblings at all', async () => { + engine.registerFlow('bare', { + name: 'bare', + label: 'Bare', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'only', type: 'mark', label: 'Only' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'only', isDefault: true }], + }); + await engine.execute('bare'); + expect(visited).toEqual(['only']); + }); + + // ── `branchLabel` — no more silent fallback ─────────────────────────── + + it('routes by branch label when an out-edge claims it', async () => { + engine.registerFlow('guard', guardFlow({ + proceedIsDefault: true, + conditions: [{ label: 'No', expression: 'true' }], + })); + await run({ status: 'converted' }); + // The decision selected 'No', which narrows traversal to `e_no` — the + // abort branch is never even evaluated. + expect(visited).toEqual(['proceed']); + expect(warnings).toHaveLength(0); + }); + + it('warns — instead of silently falling back — when no out-edge claims the label', async () => { + engine.registerFlow('guard', guardFlow({ + conditions: [ + { label: 'Yes — already converted', expression: "lead.status == 'converted'" }, + { label: 'No — proceed', expression: 'true' }, + ], + })); + await run({ status: 'converted' }); + + expect(warnings.some((w) => + w.includes("selected branch 'Yes — already converted'") + && w.includes("'Yes'") && w.includes("'No'") + && w.includes('#4414'), + )).toBe(true); + // Behaviour is unchanged (a run mid-flight must not die on it) — but it + // is no longer invisible. + expect(visited).toEqual(['abort', 'proceed']); + }); + + it('reports no branch at all from a decision that declares no conditions', async () => { + // The old executor returned `branchLabel: 'default'` here, a label no + // out-edge in the repo ever carried — so EVERY decision node fell back + // to the full edge set. Nothing to warn about now: there is no branch. + engine.registerFlow('guard', guardFlow({ proceedIsDefault: true })); + await run({ status: 'open' }); + expect(warnings).toHaveLength(0); + expect(visited).toEqual(['proceed']); + }); + + // ── `conditions[].expression` is bare CEL, as declared ──────────────── + + it('decides a branch on a bare-CEL predicate over a nested variable', async () => { + engine.registerFlow('guard', guardFlow({ + proceedIsDefault: true, + conditions: [ + { label: 'Yes', expression: "lead.status == 'converted'" }, + { label: 'No', expression: 'true' }, + ], + })); + // Handed to the legacy `{var}` template path — which is where a raw + // string used to go — `lead.status` never resolves and the first branch + // is decided by string comparison instead of by the record. + await run({ status: 'converted' }); + expect(visited).toEqual(['abort']); + + visited.length = 0; + await run({ status: 'open' }); + expect(visited).toEqual(['proceed']); + }); + + it('refuses a brace-in-CEL decision predicate rather than deciding `false`', () => { + // #4414 made this loud (it used to string-compare and decide `false` + // forever); #4439 put the slot on the expression ledger, so the refusal + // now lands at REGISTRATION and never reaches a run. See the + // registration block at the bottom of this file for the located + // diagnostic. + expect(() => engine.registerFlow('guard', guardFlow({ + proceedIsDefault: true, + conditions: [{ label: 'Yes', expression: "{lead.status} == 'converted'" }], + }))).toThrow(/template braces|bare CEL/); + }); + + it("lets the `default` sentinel claim the `isDefault` edge when no condition matched", async () => { + engine.registerFlow('guard', guardFlow({ + proceedIsDefault: true, + conditions: [{ label: 'Yes', expression: "lead.status == 'converted'" }], + })); + await run({ status: 'open' }); + // No declared condition matched → branch 'default' → the BPMN default + // edge claims it, without the author also labelling that edge 'default'. + expect(visited).toEqual(['proceed']); + expect(warnings).toHaveLength(0); + }); +}); + +/** + * #4439 — the decision's branch predicate is now on the expression ledger, so + * a brace-in-CEL predicate is a REGISTRATION error rather than a run-time one. + * + * #4414 made the failure loud; this makes it early. Before both, the raw string + * went to the legacy `{var}` template path, `{lead.status}` never resolved, and + * the branch was decided by string comparison — silently, forever. + */ +describe('decision branch predicate is validated at registration (#4439)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerLogicNodes(engine, createCtx()); + }); + + const flowWith = (expression: string) => ({ + name: 'guard', + label: 'Guard', + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'check', type: 'decision' as const, label: 'Check', config: { conditions: [{ label: 'Yes', expression }] } }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'end', label: 'Yes' }, + ], + }); + + it('rejects a brace-in-CEL branch predicate, naming the slot', () => { + const register = () => engine.registerFlow('guard', flowWith("{lead.status} == 'converted'")); + expect(register).toThrow(/\{lead\.status\} == 'converted'/); + expect(register).toThrow(/template braces|bare CEL/); + // The diagnostic must locate it — a flow may carry several branches. + expect(register).toThrow(/conditions\[0\]\.expression/); + }); + + it('accepts the bare-CEL spelling', () => { + expect(() => engine.registerFlow('guard', flowWith("lead.status == 'converted'"))).not.toThrow(); + }); +}); + +/** + * The shape objectui's flow designer actually emits, pinned. + * + * `FlowEdgeInspector.applyBranch()` copies a decision branch onto the edge it + * wires: a guarded branch becomes `{ condition, label }`, and the `true`/empty + * branch becomes `{ isDefault: true, label }`. So Studio has been writing + * `isDefault` since long before anything read it (#4414) — every Studio + * "default/else" edge ran unconditionally, in parallel with whichever branch + * matched. These flows are the ones enforcement changes, and they must now take + * exactly one path. + * + * It is also the double declaration the authoring guide tells hand-writers to + * avoid — node `conditions[]` AND per-edge `condition`s. It is correct here + * only because the designer keeps the two in sync by construction, which is + * exactly why it is worth pinning rather than assuming. + */ +describe('objectui-authored decision shape (FlowEdgeInspector.applyBranch)', () => { + let engine: AutomationEngine; + let visited: string[]; + + beforeEach(() => { + warnings.length = 0; + visited = []; + engine = new AutomationEngine(createTestLogger()); + registerLogicNodes(engine, createCtx()); + engine.registerNodeExecutor({ + type: 'mark', + async execute(node) { visited.push(node.id); return { success: true }; }, + }); + engine.registerFlow('studio', { + name: 'studio', + label: 'Studio-authored', + type: 'autolaunched', + variables: [{ name: 'order_amount', type: 'number', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'check', type: 'decision', label: 'Check Amount', + config: { + conditions: [ + { label: 'High Value', expression: 'order_amount > 10000' }, + { label: 'Standard', expression: 'true' }, + ], + }, + }, + { id: 'escalate', type: 'mark', label: 'Escalate' }, + { id: 'auto', type: 'mark', label: 'Auto approve' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + // The guarded branch: expression + label copied onto the edge. + { id: 'e2', source: 'check', target: 'escalate', label: 'High Value', condition: 'order_amount > 10000', isDefault: false }, + // The `true` branch: written as the BPMN default edge, no condition. + { id: 'e3', source: 'check', target: 'auto', label: 'Standard', isDefault: true }, + ], + }); + }); + + it('takes only the guarded branch when it matches', async () => { + await engine.execute('studio', { params: { order_amount: 20000 } } as any); + expect(visited).toEqual(['escalate']); + expect(warnings).toHaveLength(0); + }); + + it('takes only the default branch when it does not', async () => { + await engine.execute('studio', { params: { order_amount: 5000 } } as any); + expect(visited).toEqual(['auto']); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 099bf0a360..a1d805a847 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -2,7 +2,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor } from '@objectstack/spec/automation'; -import type { AutomationEngine } from '../engine.js'; +import { DEFAULT_BRANCH_LABEL, type AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; /** @@ -26,16 +26,55 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) description: 'Branch execution based on conditions.', icon: 'git-branch', category: 'logic', source: 'builtin', }), + /** + * A decision routes one of two ways, and it must not pretend to the + * other (#4414): + * + * • It DECLARED `config.conditions` → the first matching entry's + * `label` is the branch, and traversal restricts itself to the + * out-edge carrying that label. If none matched, the branch is + * {@link DEFAULT_BRANCH_LABEL} — claimed by an out-edge labelled + * `'default'` or marked `isDefault: true`. + * • It declared NONE → it is a plain gateway: the branching lives on + * the out-edges (`condition` / `isDefault`) and the node reports + * **no** branch. It used to report `'default'` regardless — a + * label no out-edge in this repo ever carried — so every decision + * node silently fell back to "consider every out-edge", which is + * the state #4414 measured (0 label matches across all example + * apps) and, on an unconditional sibling, ran both branches. + */ async execute(node, variables, _context) { const config = node.config as Record | undefined; const conditions = (config?.conditions ?? []) as Array<{ label: string; expression: string }>; + if (conditions.length === 0) return { success: true }; for (const cond of conditions) { - if (engine.evaluateCondition(cond.expression, variables)) { + // `DecisionConditionSchema.expression` is declared BARE CEL + // (ADR-0032), so pin the dialect rather than let it be + // inferred. #4453 made `evaluateCondition` sniff a bare + // string — CEL unless it contains a `{var}` hole — which + // already fixes the #4414 case this wrap was added for. + // + // The wrap still earns its place, for a different reason: the + // sniff would route a BRACED predicate to the template + // dialect and happily run it, while #4439 put this slot on + // the expression ledger as a `predicate`, so `registerFlow` + // and `objectstack validate` reject exactly that spelling. + // Without the explicit envelope the two would disagree — + // build refuses what run time accepts, the worst of both. + // An explicit `dialect: 'cel'` keeps braces the #1491 trap + // here, which is what the contract says and what the + // validators enforce. + // + // Unlike `edge.condition` this slot has no `ExpressionInput` + // envelope of its own to carry the dialect — the decision + // descriptor is deliberately schemaless — so the executor + // supplies it. + if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) { return { success: true, branchLabel: cond.label }; } } - return { success: true, branchLabel: 'default' }; + return { success: true, branchLabel: DEFAULT_BRANCH_LABEL }; }, }); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index 6b2652f95c..d8c5719098 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -1,11 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach } from 'vitest'; -import { - SCRIPT_BUILTIN_ACTION_TYPES, - SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, - ScriptConfigSchema, -} from '@objectstack/spec/automation'; +import { ScriptConfigSchema } from '@objectstack/spec/automation'; import { AutomationEngine, type FlowFunctionHandler } from '../engine.js'; import { registerScreenNodes } from './screen-nodes.js'; @@ -49,12 +45,6 @@ describe('script node (#1870 — callable resolution)', () => { registerScreenNodes(engine, createCtx()); }); - it('runs the built-in email side-effect', async () => { - engine.registerFlow('script_flow', scriptFlow({ actionType: 'email', template: 't', recipients: ['a'] })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(true); - }); - it('invokes a registered function and captures its return value as output', async () => { const calls: Array> = []; const fn: FlowFunctionHandler = (c) => { @@ -73,36 +63,16 @@ describe('script node (#1870 — callable resolution)', () => { expect(calls).toEqual([{ ticket: 't_1' }]); }); - it('resolves a bare actionType that matches no built-in as a function name', async () => { - let called = false; - engine.setFunctionResolver((name) => (name === 'pm.aiRiskAssessmentStub' ? (() => { called = true; return 1; }) : undefined)); - engine.registerFlow('script_flow', scriptFlow({ actionType: 'pm.aiRiskAssessmentStub' })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(true); - expect(called).toBe(true); - }); - it('FAILS LOUDLY for an unregistered function instead of silently no-op (#1870)', async () => { // No resolver wired → nothing resolves. engine.registerFlow('script_flow', scriptFlow({ function: 'helpdesk.aiTriageStub' })); const result = await engine.execute('script_flow', {} as any); expect(result.success).toBe(false); expect(result.error).toMatch(/aiTriageStub/); - expect(result.error).toMatch(/no function named|not a built-in/i); - }); - - it('recognizes inline config.script as a no-op (not a loud failure) — built-in runtime has no JS sandbox', async () => { - engine.registerFlow('script_flow', scriptFlow({ script: 'variables.x = 1;', outputVariables: ['x'] })); - const result = await engine.execute('script_flow', {} as any); - // Recognized form: succeeds (doesn't fail loud), but is documented as not executed. - expect(result.success).toBe(true); - }); - - it('FAILS LOUDLY when the script node declares no target at all (actionType: undefined repro)', async () => { - engine.registerFlow('script_flow', scriptFlow({ actionType: undefined })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(false); - expect(result.error).toMatch(/neither .*actionType.* nor .*function|nothing to run/i); + expect(result.error).toMatch(/no function named/i); + // It stays a ROUTABLE failure, not a guard: the function registry is the + // host's, so the same metadata succeeds where the name is registered + // (#3863). config-parse.test.ts pins that with a fault edge. }); it('surfaces a thrown function as a loud step failure', async () => { @@ -119,20 +89,11 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, # let calledWith: any; engine.setFunctionResolver((name) => name === 'helpdesk.aiTriageStub' ? ((c: any) => { calledWith = c.input; return { triaged: true }; }) : undefined); - engine.registerFlow('script_flow', scriptFlow({ actionType: 'invoke_function', functionName: 'helpdesk.aiTriageStub', inputs: { ticketId: 't1' } })); + engine.registerFlow('script_flow', scriptFlow({ functionName: 'helpdesk.aiTriageStub', inputs: { ticketId: 't1' } })); const r = await engine.execute('script_flow', {} as any); expect(r.success).toBe(true); expect(calledWith).toEqual({ ticketId: 't1' }); }); - - it('treats actionType invoke_function as a marker, not a function name', async () => { - // invoke_function alone (no `function`) must NOT try to resolve a - // function literally named 'invoke_function'; it fails with a clear message. - engine.registerFlow('script_flow', scriptFlow({ actionType: 'invoke_function' })); - const r = await engine.execute('script_flow', {} as any); - expect(r.success).toBe(false); - expect(r.error).toMatch(/invoke_function.*requires.*function/i); - }); it('exposes the function result via outputVariable for downstream nodes (pure-function pattern)', async () => { const seen: Array> = []; engine.setFunctionResolver((name) => { @@ -161,61 +122,72 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, # }); /** - * #4278 — the script node's contract is the spec-published one. The designer - * form for `script` is objectui's hand-written group (this node deliberately - * publishes no descriptor configSchema — config-schemas.test.ts), so the only - * machine-readable statement of what it accepts is - * `SCRIPT_BUILTIN_ACTION_TYPES` / `ScriptConfigSchema` in - * `@objectstack/spec/automation`. These pins are the objectstack half of the - * cross-repo reconciliation: the executor dispatches exactly the published - * built-in set (it now builds its dispatch set FROM the constant), and its - * failure message names that same set — objectui's side reconciles its form - * options and key set against the same exports. + * #4343 — what a STORED flow carrying a retired dispatch branch does now. + * + * The retirement has two channels and they reach different people. The + * `retiredKey()` tombstones teach whoever *authors* the key: `tsc` types it + * `never`, and a direct `ScriptConfigSchema` parse raises the prescription. + * They never reach a stored flow — `FlowNodeSchema.config` is + * `z.record(z.unknown())`, so no load-path parse descends into a node's config. + * + * A stored flow meets the other channel. `registerFlow` canonicalizes data at + * rest through the RETIRED conversion too (#3903 — a row in `sys_metadata` has + * no author for a tombstone to teach), so the doomed keys are stripped on + * rehydration with a logged notice, and the execute-time parse then judges + * what is left. For the branches that never delivered anything, what is left + * names no callable — so the node refuses, loudly, where it used to log a line + * and report success. That flip is the whole point of the retirement, and it is + * what these cases pin. */ -describe('script contract ↔ spec-published constants (#4278)', () => { +describe('script retired branches, as a stored flow meets them (#4343)', () => { let engine: AutomationEngine; beforeEach(() => { engine = new AutomationEngine(createTestLogger()); registerScreenNodes(engine, createCtx()); + engine.setFunctionResolver((name) => (name === 'score_lead' ? (() => 1) : undefined)); }); - it.each([...SCRIPT_BUILTIN_ACTION_TYPES])( - "every published built-in actionType runs the built-in branch: '%s'", - async (actionType) => { - engine.registerFlow('script_flow', scriptFlow({ actionType, template: 't', recipients: ['a'] })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(true); - }, - ); - - it('an actionType outside the published set fails naming exactly that set (the #4278 sms repro)', async () => { - // The old objectui form offered 'sms' / 'notification'; neither is in - // the published set, so they resolve as function names and fail. The - // error must name the published members — it is the message the #4278 - // report quoted, and the form's options now come from the same constant. - engine.registerFlow('script_flow', scriptFlow({ actionType: 'sms' })); + it.each([ + ['the email stub', { actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } }], + ['the slack stub', { actionType: 'slack', template: 't', recipients: ['#tasks'] }], + ['the example shape, payload in `inputs`', { actionType: 'email', inputs: { to: 'a@b.c' } }], + ['an inline body', { script: 'return { ok: true };' }], + ['the bare marker', { actionType: 'invoke_function' }], + ] as const)('%s no longer succeeds silently — it refuses, naming the callable it lacks', async (_name, config) => { + engine.registerFlow('script_flow', scriptFlow({ ...config })); const result = await engine.execute('script_flow', {} as any); expect(result.success).toBe(false); - for (const builtin of SCRIPT_BUILTIN_ACTION_TYPES) { - expect(result.error).toContain(builtin); - } - expect(result.error).toMatch(/'sms' is not a built-in action/); + expect(result.error).toContain('does not satisfy the script contract'); + expect(result.error).toContain('config.function'); + }); + + it('converts a shorthand `actionType` into the function it always named, and runs it', async () => { + // The one retired value carrying real intent: `actionType` that matched + // no built-in was a function name (#1870), so the conversion moves it + // rather than dropping it — and the node keeps working. + engine.registerFlow('script_flow', scriptFlow({ actionType: 'score_lead' })); + const result = await engine.execute('script_flow', {} as any); + expect(result.success).toBe(true); }); - it('the published Zod accepts the canonical authoring shapes (contract sanity)', () => { - // Function path — the only shape that does real work. + it('accepts the converged shape', async () => { + engine.registerFlow('script_flow', scriptFlow({ function: 'score_lead' })); + const result = await engine.execute('script_flow', {} as any); + expect(result.success).toBe(true); + }); + + it('the tombstones still refuse an AUTHORED key outright, prescription and all', () => { + // The other channel: no conversion runs in front of a direct parse, so + // this is what `tsc` and `os validate` put in front of an author. + const result = ScriptConfigSchema.safeParse({ function: 'score_lead', actionType: 'email' }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toMatch(/#4343/); expect(ScriptConfigSchema.parse({ - actionType: SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, function: 'score_lead', inputs: { leadId: '{record.id}' }, outputVariable: 'score', })).toMatchObject({ function: 'score_lead' }); - // Built-in side effect. - expect(ScriptConfigSchema.parse({ actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } })) - .toMatchObject({ actionType: 'email' }); - // Inline script — recognized (and documented as not executed). - expect(ScriptConfigSchema.parse({ script: 'return 1;' })).toMatchObject({ script: 'return 1;' }); }); }); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 25b1003f26..9f2ca5accb 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -1,8 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor, ScreenConfigSchema, SCRIPT_BUILTIN_ACTION_TYPES } from '@objectstack/spec/automation'; -import type { ScreenConfigParsed } from '@objectstack/spec/automation'; +import { defineActionDescriptor, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; +import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; import { parseNodeConfig } from './parse-config.js'; @@ -19,28 +19,24 @@ import { parseNodeConfig } from './parse-config.js'; * as bare flow variables). A field-less screen — or one with * `waitForInput === false` — stays a server pass-through (input vars, if any, * are already injected from `context.params`). - * - 'script' nodes name a callable to run (#1870): - * - `config.actionType` selecting a built-in side-effect ('email', 'slack', - * logger-backed), or - * - `config.function` (or a bare `actionType` that matches no built-in) - * naming a registered function — resolved via `engine.resolveFunction()`, - * which the host bridges to `bundle.functions` / `defineStack({ functions })`. - * A target that resolves to neither fails the step LOUDLY rather than the old - * silent "no-op handler" success, so an unwired callable can't quietly skip. - * The named function is contractually PURE — it returns a value and the flow - * graph persists it — which the descriptor publishes as - * `handlerContract: 'pure'` and a writing function opts out of by declaring - * `effect: 'writes'` where it is registered (#4396). - */ - -/** - * Built-in `script` side-effect action types with a (logger-backed) handler. - * Anything else is treated as a registered-function name (#1870). The member - * list is the spec-published `SCRIPT_BUILTIN_ACTION_TYPES` — the same constant - * the designer's `actionType` options reconcile against (#4278), so the form, - * this dispatch set, and the failure message below cannot disagree. + * - 'script' nodes call a registered function (#1870): `config.function` names + * it, `engine.resolveFunction()` resolves it, and the host bridges that to + * `bundle.functions` / `defineStack({ functions })`. A name that resolves to + * nothing fails the step LOUDLY rather than the old silent "no-op handler" + * success, so an unwired callable can't quietly skip. The named function is + * contractually PURE — it returns a value and the flow graph persists it — + * which the descriptor publishes as `handlerContract: 'pure'` and a writing + * function opts out of by declaring `effect: 'writes'` where it is registered + * (#4396). + * + * #4343 converged this node to that single path. `config.actionType`'s + * built-in side effects ('email' / 'slack') were logger-backed stubs that + * delivered nothing, inline `config.script` was never executed (no + * server-side JS sandbox), and any other `actionType` was a second spelling + * of `config.function`. All five keys are retired in the spec contract, which + * this executor now parses before it runs — see the note in `execute` for why + * that parse is what makes the retirement audible to stored metadata. */ -const SCRIPT_BUILTINS = new Set(SCRIPT_BUILTIN_ACTION_TYPES); export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void { // screen — server-side pass-through (input vars already injected by engine). @@ -201,7 +197,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }, }); - // script — dispatch by actionType. + // script — call the registered function named by `config.function`. engine.registerNodeExecutor({ type: 'script', descriptor: defineActionDescriptor({ @@ -215,66 +211,41 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext handlerContract: 'pure', }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - // The historical aliases (`functionName`/`input`) are canonicalized at - // load by the ADR-0087 D2 conversion 'flow-node-script-config-aliases' - // (#3796), so only the canonical keys are read here. - const fnRaw = cfg.function; - const fnName = typeof fnRaw === 'string' && fnRaw.trim() ? fnRaw.trim() : undefined; - const actionType = typeof cfg.actionType === 'string' && cfg.actionType.trim() ? cfg.actionType.trim() : undefined; - - // Built-in side-effect actions keep their logger-backed behavior — but - // only when an explicit `function` isn't set (that always wins). - if (!fnName && actionType && SCRIPT_BUILTINS.has(actionType)) { - ctx.logger.info( - `[Script:${actionType}] template=${String(cfg.template)} ` + - `recipients=${JSON.stringify(cfg.recipients)} ` + - `vars=${JSON.stringify(cfg.variables)}`, - ); - return { - success: true, - output: { actionType, template: cfg.template, recipients: cfg.recipients }, - }; - } - - // Inline `config.script` (a JS source body) is a distinct, recognized - // form — but the built-in runtime has no server-side JS sandbox, so it - // does not execute it. Warn loudly (not a silent success) and steer the - // author to the supported path — a registered function — rather than - // failing the flow. Executing inline scripts is a separate capability, - // out of #1870's callable-resolution scope. - const inlineScript = typeof cfg.script === 'string' && cfg.script.trim() ? cfg.script : undefined; - if (!fnName && inlineScript) { - ctx.logger.warn( - `[Script] node '${node.id}': inline \`config.script\` is not executed by the built-in runtime ` + - `(no server-side JS sandbox) — this node is a no-op. To run server logic, move it into a ` + - `registered function and call it via \`config.function\` + \`defineStack({ functions })\`.`, - ); - return { success: true, output: { script: 'not-executed' } }; - } - - // `actionType: 'invoke_function'` is a MARKER meaning "call the named - // function" — the name lives in `function`, not in actionType itself. A - // bare actionType that matched no built-in is still accepted as a - // function name (shorthand). - const target = fnName ?? (actionType === 'invoke_function' ? undefined : actionType); - if (!target) { - return { - success: false, - error: - actionType === 'invoke_function' - ? `script node '${node.id}': actionType 'invoke_function' requires \`config.function\` naming the function to call.` - : `script node '${node.id}': declares neither \`actionType\` nor \`function\` — nothing to run.`, - }; - } + // #4343 — the contract is parsed before anything runs. A `script` node + // calls a registered function and nothing else, so `config.function` is + // required and a retired dispatch key refuses here with its tombstone + // prescription (`notify` for mail, a Slack connector for slack, a + // registered function for an inline body). + // + // A refusal is a GUARD, not a routable failure: a config that fails its + // contract is wrong METADATA — re-running it unchanged can never + // succeed — so no `fault` edge may silence it (#3863). + // + // What a STORED flow meets here is the required `function`, not the + // tombstones: `registerFlow` canonicalizes data at rest through the + // retired conversion as well (#3903), so an old `actionType: 'email'` + // node arrives stripped of the keys nothing read, and refuses for + // naming no callable. That is the behavioral change the retirement + // bought — the same node used to log a line and report success. + // + // The historical aliases (`functionName`/`input`) are canonicalized on + // the same seam by 'flow-node-script-config-aliases' (#3796), so only + // the canonical keys reach the parse. + const parsed = parseNodeConfig('script', node.id, ScriptConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const target = cfg.function.trim(); + // Unresolvable is a RUNTIME failure, deliberately: the function + // registry is the host's (`defineStack({ functions })`), so the same + // metadata succeeds on a host that registers the name. Only the + // metadata itself earns a guard. const registration = engine.resolveFunction(target); if (!registration) { return { success: false, error: - `script node '${node.id}': '${target}' is not a built-in action ` + - `(${[...SCRIPT_BUILTINS].join(', ')}) and no function named '${target}' is registered. ` + + `script node '${node.id}': no function named '${target}' is registered. ` + `Register it via \`defineStack({ functions: { '${target}': fn } })\`, or fix the name (#1870).`, }; } @@ -283,8 +254,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // `{var}` references against the live flow variables (so a function can // consume a prior node's output, e.g. `{aiResult.id}`). const input = interpolate(cfg.inputs ?? {}, variables, context) as Record; - const outputVariable = - typeof cfg.outputVariable === 'string' && cfg.outputVariable.trim() ? cfg.outputVariable.trim() : undefined; + const outputVariable = cfg.outputVariable?.trim() || undefined; // Pure-function pattern: the function RETURNS its result; `outputVariable` // exposes it as a flow variable so a later declarative node persists it // (e.g. `update_record fields: { ai_category: '{aiResult.ai_category}' }`). diff --git a/packages/services/service-automation/src/builtin/screen-resume-validation.test.ts b/packages/services/service-automation/src/builtin/screen-resume-validation.test.ts new file mode 100644 index 0000000000..ea036b5a8a --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-resume-validation.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `resume` enforces the suspended screen's declared field contract (#4477). + * + * The specimen is the issue's, verbatim: a two-field screen whose second field + * is `required` and conditional on the first. The RENDER half already worked — + * the paused result and `GET …/runs/:runId/screen` carry `required` and + * `visibleWhen` intact. There was no validation half: `POST …/resume` folded + * whatever bag it was handed straight into the flow variables, so all four + * shapes below completed the run with `success: true`. A client that skipped + * the dialog and posted to `resume` directly was unconstrained by every + * `required` the author declared. + * + * Screen flows are the one place where the declared field contract is the ONLY + * contract — no object schema sits behind a screen node to catch a bad bag + * downstream, unlike action params (ADR-0104 D2), record writes (ADR-0113) and + * approval `decisionOutputs` (#3447), all of which already enforce theirs. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function ctx() { + return { logger: silentLogger(), getService() { return undefined; } } as any; +} + +/** The issue's specimen: `kind` unconditionally required, `escalation_reason` + * required only when `kind == 'escalate'`. */ +function triageFlow() { + return { + name: 'triage', + label: 'Triage', + type: 'screen', + status: 'active', + version: 1, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', type: 'screen', label: 'Triage', + config: { + fields: [ + { name: 'kind', label: 'Kind', type: 'text', required: true }, + { + name: 'escalation_reason', label: 'Escalation reason', type: 'text', + required: true, visibleWhen: "kind == 'escalate'", + }, + ], + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask', type: 'default' }, + { id: 'e2', source: 'ask', target: 'end', type: 'default' }, + ], + }; +} + +describe('screen resume validation (#4477)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + engine.registerFlow('triage', triageFlow() as any); + }); + + /** Run to the screen pause and return the run id. */ + async function pause(): Promise { + const started = await engine.execute('triage', {} as any); + expect(started.status).toBe('paused'); + expect(started.screen?.nodeId).toBe('ask'); + return started.runId!; + } + + it('still accepts the conforming submission — the conditional field stays hidden', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { variables: { kind: 'normal' } }); + expect(res.success).toBe(true); + expect(res.code).toBeUndefined(); + }); + + it('accepts the conditional field when its predicate is TRUE and the value is there', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { + variables: { kind: 'escalate', escalation_reason: 'customer churn risk' }, + }); + expect(res.success).toBe(true); + }); + + // ── The four rows of the issue's table ──────────────────────────────── + + it('rejects a VISIBLE conditional field whose required value is missing', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { variables: { kind: 'escalate' } }); + expect(res.success).toBe(false); + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + expect(res.error).toContain('escalation_reason'); + expect(res.error).toMatch(/required/i); + }); + + it('rejects a missing UNCONDITIONAL required field', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { variables: {} }); + expect(res.success).toBe(false); + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + expect(res.error).toContain('kind'); + }); + + it('rejects an UNDECLARED key', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { variables: { kind: 'normal', totally_bogus: 'x' } }); + expect(res.success).toBe(false); + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + expect(res.error).toContain('totally_bogus'); + // The declared list rides along, so the caller can self-correct — the + // same courtesy `decisionOutputs` extends (#3447). + expect(res.error).toContain("'kind'"); + expect(res.error).toContain("'escalation_reason'"); + }); + + it('treats an empty string / null as absent, not as a value', async () => { + for (const value of ['', ' ', null]) { + const runId = await pause(); + const res = await engine.resume(runId, { variables: { kind: value } }); + expect(res.success, `kind=${JSON.stringify(value)}`).toBe(false); + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + } + }); + + // ── The refusal must not consume the pause ──────────────────────────── + + it('leaves the run resumable after a refusal — the pause is never consumed', async () => { + const runId = await pause(); + const bad = await engine.resume(runId, { variables: {} }); + expect(bad.success).toBe(false); + // The screen is still fetchable… + expect(engine.getSuspendedScreen(runId)?.nodeId).toBe('ask'); + // …and the legitimate submission still lands. + const good = await engine.resume(runId, { variables: { kind: 'normal' } }); + expect(good.success).toBe(true); + }); + + it('reports EVERY violation at once, not just the first', async () => { + const runId = await pause(); + const res = await engine.resume(runId, { variables: { escalation_reason: 'x', bogus: 1 } }); + expect(res.success).toBe(false); + // `kind` missing (required, unconditional) AND `bogus` undeclared. + expect(res.error).toContain('kind'); + expect(res.error).toContain('bogus'); + }); +}); + +describe('screen resume validation — screens that declare no contract (#4477)', () => { + function flowWith(nodeConfig: Record) { + return { + name: 'no_contract', label: 'No contract', type: 'screen', status: 'active', version: 1, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'ask', type: 'screen', label: 'Ask', config: nodeConfig }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask', type: 'default' }, + { id: 'e2', source: 'ask', target: 'end', type: 'default' }, + ], + }; + } + + it('leaves a MESSAGE-ONLY screen a pass-through — it declares no keys, so it constrains none', async () => { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + engine.registerFlow('no_contract', flowWith({ title: 'Confirm', waitForInput: true }) as any); + const started = await engine.execute('no_contract', {} as any); + expect(started.status).toBe('paused'); + const res = await engine.resume(started.runId!, { variables: { acknowledged: true } }); + expect(res.success).toBe(true); + }); + + it('leaves an OBJECT-FORM screen a pass-through — the client persists the record and resumes with the id', async () => { + // Its `fields` is `[]` by construction; validating against that would + // reject the `idVariable` binding as an undeclared key, and the object's + // own `required` fields are enforced on the write path (ADR-0113). + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + engine.registerFlow('no_contract', flowWith({ + objectName: 'crm_account', mode: 'create', idVariable: 'account_id', + }) as any); + const started = await engine.execute('no_contract', {} as any); + expect(started.status).toBe('paused'); + expect(started.screen?.kind).toBe('object-form'); + const res = await engine.resume(started.runId!, { variables: { account_id: 'acc_1' } }); + expect(res.success).toBe(true); + }); +}); + +describe('screen resume validation — visibleWhen edge cases (#4477)', () => { + /** A conditional-required field whose predicate references a variable that + * is neither submitted nor in the run's snapshot. */ + function flowWithPredicate(visibleWhen: string) { + return { + name: 'pred', label: 'Pred', type: 'screen', status: 'active', version: 1, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', type: 'screen', label: 'Ask', + config: { fields: [{ name: 'note', label: 'Note', type: 'text', required: true, visibleWhen }] }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask', type: 'default' }, + { id: 'e2', source: 'ask', target: 'end', type: 'default' }, + ], + }; + } + + async function resumeWith(visibleWhen: string, bag: Record) { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + engine.registerFlow('pred', flowWithPredicate(visibleWhen) as any); + const started = await engine.execute('pred', {} as any); + return engine.resume(started.runId!, { variables: bag }); + } + + it('does not fire `required` for a field whose predicate is FALSE', async () => { + expect((await resumeWith('false', {})).success).toBe(true); + }); + + it('fires `required` for a field whose predicate is TRUE', async () => { + const res = await resumeWith('true', {}); + expect(res.success).toBe(false); + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + }); + + it('does NOT reject when the predicate cannot be evaluated — the client decides what was shown', async () => { + // An unevaluable predicate is not evidence the field was on screen, so + // treating it as visible would reject a submission the user could never + // have completed — #3528's dead-end, moved server-side. Logged loudly + // instead (see `refuseInvalidScreenInput`). + const res = await resumeWith('nonexistent_var.deep.path == 1', {}); + expect(res.success).toBe(true); + }); + + it('still refuses an undeclared key on a screen whose predicate is unevaluable', async () => { + // The `required` degradation is scoped to `required` — the undeclared-key + // half of the contract does not depend on any predicate. + const res = await resumeWith('nonexistent_var.deep.path == 1', { note: 'x', rogue: 1 }); + expect(res.success).toBe(false); + expect(res.error).toContain('rogue'); + }); + + it('evaluates the predicate against the SUBMITTED values, not just the snapshot', async () => { + // `kind` exists only in this submission; the run's variable snapshot has + // nothing. A predicate resolved against the snapshot alone would read + // the field as hidden and let the missing value through. + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + engine.registerFlow('triage', triageFlow() as any); + const started = await engine.execute('triage', {} as any); + const res = await engine.resume(started.runId!, { variables: { kind: 'escalate' } }); + expect(res.success).toBe(false); + expect(res.error).toContain('escalation_reason'); + }); +}); diff --git a/packages/services/service-automation/src/builtin/subflow-node.test.ts b/packages/services/service-automation/src/builtin/subflow-node.test.ts index b29e62bc80..e4516ae33a 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.test.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.test.ts @@ -128,7 +128,10 @@ describe('subflow node executor', () => { engine.registerFlow('parent_flow', parentFlow({ input: {} })); const result = await engine.execute('parent_flow'); expect(result.success).toBe(false); - expect(result.error).toMatch(/flowName is required/i); + // #4343 — the hand-written guard became the contract parse; same guard + // classification, message now derived from `SubflowConfigSchema`. + expect(result.error).toMatch(/does not satisfy the subflow contract/i); + expect(result.error).toMatch(/config\.flowName/); }); it('fails with a clear error when the child flow is not registered', async () => { diff --git a/packages/services/service-automation/src/builtin/subflow-node.ts b/packages/services/service-automation/src/builtin/subflow-node.ts index 4c881e0bc8..8ae1dca015 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.ts @@ -1,11 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, SubflowConfigSchema } from '@objectstack/spec/automation'; +import type { SubflowConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; import { refuseNode } from '../guard-refusal.js'; +import { parseNodeConfig } from './parse-config.js'; /** Hard cap on subflow nesting — turns an accidental cycle into a clean error. */ const MAX_SUBFLOW_DEPTH = 16; @@ -52,14 +54,18 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext supportsPause: true, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; + // #4343 — the contract is parsed before anything runs, the same seam the + // flat builtins got in #4277: a missing or empty `flowName` refuses this + // node as a GUARD (wrong metadata; a rerun cannot supply it), with the + // path named, instead of the hand-written check this replaces. + // // The historical `flow` alias is canonicalized at load by the ADR-0087 D2 // conversion 'flow-node-subflow-flow-alias' (#4278), so only the - // canonical key is read here (contract: SubflowConfigSchema). - const flowName = typeof cfg.flowName === 'string' ? cfg.flowName : undefined; - if (!flowName) { - return refuseNode(`subflow '${node.id}': config.flowName is required`); - } + // canonical key reaches the parse. + const parsed = parseNodeConfig('subflow', node.id, SubflowConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const flowName = cfg.flowName; // Cycle guard: depth rides on the context so it accumulates across nesting. const depth = Number((context as { $subflowDepth?: number } | undefined)?.$subflowDepth ?? 0); @@ -72,10 +78,9 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext } // Map inputs (resolve `{var}` against the parent's variables/context). - const rawInput = (cfg.input && typeof cfg.input === 'object' ? cfg.input : {}) as Record; - const params = interpolate(rawInput, variables, context ?? ({} as AutomationContext)) as Record; + const params = interpolate(cfg.input ?? {}, variables, context ?? ({} as AutomationContext)) as Record; - const outVar = typeof cfg.outputVariable === 'string' && cfg.outputVariable ? cfg.outputVariable : undefined; + const outVar = cfg.outputVariable || undefined; // Parent linkage for nested durable pause: should the child suspend, the // engine persists these with the child run and uses them to bubble the diff --git a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts new file mode 100644 index 0000000000..b241807dfe --- /dev/null +++ b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4454 — `AutomationEngine.canonicalizeStoredFlow`: one policy, two shapes. + * + * `os migrate meta --stored` (#4327) covers every metadata type except `flow`, + * because flow-node conversions carry ADR-0078's open-namespace conflict guard + * and that needs the engine's live executor registry. This method is the entry + * point that lets a caller outside the load seam ask for a flow's canonical + * shape without registering (and thereby arming) it. + * + * The interesting half is what `storable` deliberately does NOT contain. + * `FlowSchema.parse` materializes defaults — `version`, `runAs`, per-edge + * `type` / `isDefault` — and persisting a default the author never wrote pins + * that row to today's value while untouched rows follow tomorrow's. Two + * populations with different behaviour is precisely the drift a + * canonicalization pass exists to remove, so these pin that it stays out. + */ +import { describe, expect, it, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; + +const silentLogger = { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), +} as any; + +/** + * A pre-17 flow: the retired `filters` alias on a crud node at top level AND + * inside a `loop` body, plus bare-string edge conditions in both places (the + * #4347 nesting case). + */ +const legacyFlow = () => ({ + name: 'sweep_stale', + label: 'Sweep Stale Leads', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'lead', triggerType: 'record-after-update' } }, + { id: 'n1', type: 'delete_record', label: 'Delete Stale', config: { objectName: 'lead', filters: { status: 'stale' } } }, + { + id: 'loop1', + type: 'loop', + label: 'Per Item', + config: { + collection: '{record.items}', + // A well-formed region: single entry (b1), single exit (b2), + // acyclic — `validateControlFlow` rejects anything else, and it + // runs before the region canonicalization under test. + body: { + nodes: [ + { id: 'b1', type: 'update_record', label: 'Touch', config: { objectName: 'lead', filters: { id: '{item.id}' } } }, + { id: 'b2', type: 'update_record', label: 'Mark', config: { objectName: 'lead', filter: { id: '{item.id}' } } }, + ], + edges: [{ id: 'be1', source: 'b1', target: 'b2', condition: "status == 'x'" }], + }, + }, + }, + ], + edges: [ + { id: 'e0', source: 'start', target: 'n1' }, + { id: 'e1', source: 'n1', target: 'loop1', condition: "status == 'y'" }, + ], +}); + +describe('canonicalizeStoredFlow — the storable shape (#4454)', () => { + it('lowers the retired filters alias, including inside a loop body', () => { + const engine = new AutomationEngine(silentLogger); + const { storable } = engine.canonicalizeStoredFlow('sweep_stale', legacyFlow()); + const s = storable as any; + + expect(s.nodes[1].config.filter).toEqual({ status: 'stale' }); + expect('filters' in s.nodes[1].config).toBe(false); + // The conversion pass already reaches into regions — only the condition + // envelope needs the schema's help. + expect(s.nodes[2].config.body.nodes[0].config.filter).toEqual({ id: '{item.id}' }); + expect('filters' in s.nodes[2].config.body.nodes[0].config).toBe(false); + }); + + it('lifts the condition envelope at BOTH nesting depths', () => { + const engine = new AutomationEngine(silentLogger); + const { storable } = engine.canonicalizeStoredFlow('sweep_stale', legacyFlow()); + const s = storable as any; + + expect(s.edges[1].condition).toEqual({ dialect: 'cel', source: "status == 'y'" }); + // #4347's asymmetry is what made this worth doing: the identical + // predicate one level in used to keep its bare-string shape. + expect(s.nodes[2].config.body.edges[0].condition).toEqual({ dialect: 'cel', source: "status == 'x'" }); + }); + + it('does NOT persist schema defaults — a migrated row must not freeze on today\'s values', () => { + const engine = new AutomationEngine(silentLogger); + const { storable, parsed } = engine.canonicalizeStoredFlow('sweep_stale', legacyFlow()); + const s = storable as any; + + // The parse materializes these; the stored shape must not carry them, + // or every migrated row is pinned to today's default while untouched + // rows follow tomorrow's. + expect('version' in s).toBe(false); + expect('runAs' in s).toBe(false); + expect('type' in s.edges[0]).toBe(false); + expect('isDefault' in s.edges[0]).toBe(false); + + // …while the EXECUTION shape does carry them, which is the whole + // reason the two shapes are distinct. + expect((parsed as any).version).toBeDefined(); + expect((parsed as any).edges[0].type).toBeDefined(); + }); + + it('leaves an already-canonical flow byte-identical — the pass is a no-op on protocol', () => { + const engine = new AutomationEngine(silentLogger); + const canonical = engine.canonicalizeStoredFlow('sweep_stale', legacyFlow()).storable; + const second = engine.canonicalizeStoredFlow('sweep_stale', canonical).storable; + + expect(JSON.stringify(second)).toBe(JSON.stringify(canonical)); + // …and reports nothing, which is what lets a re-run report "canonical". + expect(engine.canonicalizeStoredFlow('sweep_stale', canonical).notices).toEqual([]); + }); + + it('reports the conversions it applied, so a migration can name them per row', () => { + const engine = new AutomationEngine(silentLogger); + const { notices } = engine.canonicalizeStoredFlow('sweep_stale', legacyFlow()); + + expect(notices.length).toBeGreaterThan(0); + expect(notices.some((n) => n.from === 'filters' && n.to === 'filter')).toBe(true); + }); + + it('leaves a node config predicate alone — the parse never lowers an open z.record', () => { + const engine = new AutomationEngine(silentLogger); + const flow = legacyFlow(); + (flow.nodes[0].config as any).condition = 'title != previous.title'; + const { storable } = engine.canonicalizeStoredFlow('sweep_stale', flow); + + // A start node's record-change predicate is config, not an edge — no + // envelope exists on the parsed side, so the graft must not invent one. + expect((storable as any).nodes[0].config.condition).toBe('title != previous.title'); + }); + + it('throws on an unrecognized key rather than silently dropping it (#4001)', () => { + const engine = new AutomationEngine(silentLogger); + const flow = { ...legacyFlow(), _uiPosition: { x: 1, y: 2 } }; + + // FlowSchema is strict. A stored row carrying this cannot be registered + // at all, so a migration must report it failed — never persist a guess. + expect(() => engine.canonicalizeStoredFlow('sweep_stale', flow)).toThrow(); + }); +}); + +describe('registerFlow still behaves identically (#4454 refactor)', () => { + it('registers the converted flow and serves the parsed shape', async () => { + const engine = new AutomationEngine(silentLogger); + engine.registerFlow('sweep_stale', legacyFlow()); + + const flow: any = await engine.getFlow('sweep_stale'); + expect(flow).not.toBeNull(); + // Execution sees canonical config… + expect(flow.nodes[1].config.filter).toEqual({ status: 'stale' }); + // …and the defaults it needs. + expect(flow.version).toBeDefined(); + }); +}); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index f3c5cf9615..d560ca1b8a 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1905,10 +1905,15 @@ describe('AutomationEngine - Safe Expression Evaluation', () => { it('should not execute malicious code', () => { const vars = new Map(); - // These should all return false safely - expect(engine.evaluateCondition('process.exit(1)', vars)).toBe(false); - expect(engine.evaluateCondition('require("fs").readFileSync("/etc/passwd")', vars)).toBe(false); - expect(engine.evaluateCondition('(() => { while(true) {} })()', vars)).toBe(false); + // None of these is a host-language program to this engine — there is no + // `new Function`, no `eval`, no `require` on either path. They REFUSE + // rather than return `false` since #4336: a brace-free condition is CEL, + // and CEL has no `process`, no `require`, and no arrow-function syntax, + // so each one is a fault the run reports. The safety property is + // unchanged (nothing executes) and the diagnosis is no longer silent. + expect(() => engine.evaluateCondition('process.exit(1)', vars)).toThrow(/exit/); + expect(() => engine.evaluateCondition('require("fs").readFileSync("/etc/passwd")', vars)).toThrow(/require/); + expect(() => engine.evaluateCondition('(() => { while(true) {} })()', vars)).toThrow(/source:/); }); it('should handle string comparisons', () => { @@ -1917,6 +1922,106 @@ describe('AutomationEngine - Safe Expression Evaluation', () => { expect(engine.evaluateCondition('{status} == active', vars)).toBe(true); expect(engine.evaluateCondition('{status} != inactive', vars)).toBe(true); + // #4336 — a QUOTED literal on the right compares as its contents. This is + // the spelling the flow docs show for a decision node, and it used to + // compare `active` against `'active'` (quotes included) and be false for + // every value of `status`. + expect(engine.evaluateCondition("{status} == 'active'", vars)).toBe(true); + expect(engine.evaluateCondition('{status} == "active"', vars)).toBe(true); + expect(engine.evaluateCondition("{status} == 'closed'", vars)).toBe(false); + expect(engine.evaluateCondition("{status} != 'closed'", vars)).toBe(true); + }); +}); + +// ─── #4336: a bare-string condition is CEL, not a string compare ───── +// +// The reported defect: `evaluateCondition` branched on whether an `Expression` +// envelope was present, so a condition authored as a plain string never reached +// the CEL engine and both sides were compared as TEXT. Every case below is one +// of the two failure directions from the issue (a gate that never opens, a +// branch pinned open) or one of the two silent `false`s found afterwards. +describe('AutomationEngine - bare-string conditions evaluate as CEL (#4336)', () => { + let engine: AutomationEngine; + beforeEach(() => { engine = new AutomationEngine(createTestLogger()); }); + + it('opens a null-check gate that used to be pinned shut', () => { + // `'existingTask' === 'null'` → false, forever. The flow selected its + // records, took no branch, and recorded `success`. + expect(engine.evaluateCondition('existingTask == null', new Map([['existingTask', null]]))).toBe(true); + expect(engine.evaluateCondition('existingTask == null', new Map([['existingTask', { id: 'a' }]]))).toBe(false); + }); + + it('gates a numeric comparison that used to be pinned open', () => { + // `'record.rating' >= '4'` → `'r' > '4'` → true for every record. + const low = new Map([['record', { rating: 2 }]]); + const high = new Map([['record', { rating: 5 }]]); + expect(engine.evaluateCondition('record.rating >= 4', high)).toBe(true); + expect(engine.evaluateCondition('record.rating >= 4', low)).toBe(false); + }); + + it('evaluates a bare truthy gate instead of answering false', () => { + // No comparison operator at all: the template path fell through to + // `Number('record.isActive')` → NaN → `false`. + expect(engine.evaluateCondition('record.isActive', new Map([['record', { isActive: true }]]))).toBe(true); + expect(engine.evaluateCondition('record.isActive', new Map([['record', { isActive: false }]]))).toBe(false); + }); + + it('resolves field access on an object variable — the get_record output shape', () => { + // `get_record`'s `outputVariable` stores the WHOLE record under one name, + // which is why the `{lead_record.status}` spelling can never resolve. + const vars = new Map([['lead_record', { status: 'converted' }]]); + expect(engine.evaluateCondition("lead_record.status == 'converted'", vars)).toBe(true); + expect(engine.evaluateCondition("lead_record.status == 'new'", vars)).toBe(false); + }); + + it('refuses a brace-wrapped reference that resolves to nothing, naming it', () => { + const vars = new Map([['lead_record', { status: 'converted' }]]); + // Silently false today even though the status IS 'converted'. + expect(() => engine.evaluateCondition("{lead_record.status} == 'converted'", vars)) + .toThrow(/`\{lead_record\.status\}` did not resolve/); + expect(() => engine.evaluateCondition("{lead_record.status} == 'converted'", vars)) + .toThrow(/Drop the braces/); + // Same for a brace-wrapped truthy gate. + expect(() => engine.evaluateCondition('{record.isActive}', new Map([['record', { isActive: true }]]))) + .toThrow(/did not resolve/); + }); + + it('refuses a template-dialect condition it cannot turn into a predicate', () => { + // `{status}` substitutes to `open` — not a boolean, not a number, and no + // operator to compare it with. That used to be `false`. + expect(() => engine.evaluateCondition('{status}', new Map([['status', 'open']]))) + .toThrow(/is not a predicate/); + // A boolean or numeric value still reads as a gate. + expect(engine.evaluateCondition('{flag}', new Map([['flag', true]]))).toBe(true); + expect(engine.evaluateCondition('{flag}', new Map([['flag', false]]))).toBe(false); + expect(engine.evaluateCondition('{count}', new Map([['count', 3]]))).toBe(true); + expect(engine.evaluateCondition('{count}', new Map([['count', 0]]))).toBe(false); + }); + + it('does not mistake braces inside a string literal for a template hole', () => { + // `'{pending}'` is text the predicate compares AGAINST, not a reference to + // substitute. The dialect sniff reads the source outside string literals, + // so this stays CEL and compares the field. + expect(engine.evaluateCondition("record.label == '{pending}'", + new Map([['record', { label: '{pending}' }]]))).toBe(true); + expect(engine.evaluateCondition("record.label == '{pending}'", + new Map([['record', { label: 'pending' }]]))).toBe(false); + }); + + it('keeps braces inside an explicit CEL envelope a hard error', () => { + // The dialect sniff applies only where no dialect was stated. `dialect: + // 'cel'` is the author saying "this is CEL", where `{…}` is a map literal + // and the #1491 brace-trap. + expect(() => engine.evaluateCondition({ dialect: 'cel', source: '{record.rating} >= 4' }, + new Map([['record', { rating: 5 }]]))).toThrow(/source:/); + }); + + it('treats an absent or empty condition as no branch, not as a fault', () => { + // A `decision` entry with no `expression` is the one caller that does not + // pre-check; an unauthored branch must not open, and must not throw either. + expect(engine.evaluateCondition('', new Map())).toBe(false); + expect(engine.evaluateCondition(' ', new Map())).toBe(false); + expect(engine.evaluateCondition(undefined as unknown as string, new Map())).toBe(false); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7f805c97d8..936e5dd485 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -9,12 +9,18 @@ import type { FlowFunctionEffect, FlowRunSummary, } from '@objectstack/spec/automation'; -import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec } from '@objectstack/spec/contracts'; +import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec, ScreenFieldSpec } from '@objectstack/spec/contracts'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; +import { + validateScreenInputs, + screenDeclaresInputContract, + declaredScreenFieldNames, + type ScreenFieldVisibility, +} from './screen-input-contract.js'; import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, normalizeControlFlowRegions, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation'; import { resolveFlowNodeExpressions } from '@objectstack/spec/automation'; -import { applyConversionsToFlow } from '@objectstack/spec'; +import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec'; import type { FlowRegionParsed } from '@objectstack/spec/automation'; import type { Connector, @@ -41,6 +47,65 @@ import { ExpressionEngine, validateExpression, nearestName } from '@objectstack/ */ const UNRESOLVED_CEL_REFERENCE = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/; +/** + * A legacy single-brace **template hole** — `{amount}`, `{get_lead.id}`. This is + * the exact shape `{var}` substitution can consume (it splits on the literal + * `{}` text for each variable key), which is what makes it a sound dialect + * discriminator in {@link AutomationEngine.evaluateCondition}: a condition + * containing one was written in the template dialect, and a condition containing + * none was written as CEL (#4336). + * + * No whitespace is tolerated inside the braces, deliberately — `{ amount }` + * is not a token substitution can resolve, so treating it as a hole would only + * move the failure. It is not valid CEL either (a map literal needs `key: value` + * pairs), so it lands on the CEL path and gets the brace-trap diagnostic. + */ +const TEMPLATE_HOLE = /\{[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*\}/g; + +/** A quoted string literal, anywhere in a condition. */ +const QUOTED_SEGMENT = /'[^']*'|"[^"]*"/g; + +/** A single-quoted or double-quoted string literal, whole-operand. */ +const QUOTED_LITERAL = /^'([^']*)'$|^"([^"]*)"$/; + +/** + * Every legacy `{var}` hole in `source`, ignoring any that sits **inside a + * string literal** — `record.name == '{unresolved}'` is a CEL predicate + * comparing against text that happens to contain braces, not a template. + * + * This is both the dialect discriminator and the unresolved-hole report, so the + * two can never disagree about what counts as a hole. It reads the AUTHORED + * source rather than the substituted result, so a variable whose *value* + * contains braces cannot masquerade as an unresolved reference. + */ +function templateHoles(source: string): string[] { + return source.replace(QUOTED_SEGMENT, '').match(TEMPLATE_HOLE) ?? []; +} + +/** + * Strip one layer of matching quotes from a template-dialect operand, so a + * quoted string literal compares as its contents (#4336). Anything that is not + * a whole quoted literal is returned untouched — including a value that merely + * contains a quote. + */ +function unquoteLiteral(operand: string): string { + const m = QUOTED_LITERAL.exec(operand); + return m ? (m[1] ?? m[2] ?? '') : operand; +} + +/** + * The branch a `decision` node reports when it DECLARED `config.conditions` and + * none of them matched — "fall through to the declared fallback". + * + * Two spellings claim it in {@link AutomationEngine.traverseNext}: an out-edge + * literally `label`led `'default'` (the historical, documented spelling) and an + * out-edge marked `isDefault: true` (the BPMN default flow, canonical since + * #4414). A decision that declares NO conditions reports no branch at all — it + * has nothing to fall through *from*, and inventing a label for it is what made + * every decision node in the repo emit an unclaimable `'default'`. + */ +export const DEFAULT_BRANCH_LABEL = 'default'; + /** * The slice of a descriptor's JSON-Schema `configSchema` that the undeclared-key * walk reads (#4045). Structural only — no validation semantics. @@ -857,6 +922,69 @@ export interface SuspendedRunStore { loadTerminal?(runId: string): Promise; } +/** + * Lift the `{ dialect, source }` envelopes the flow schema derives for edge + * `condition`s back onto the conversion output — and take nothing else with + * them (#4454). + * + * This is the persistence half of {@link AutomationEngine.canonicalizeStoredFlow}. + * A stored flow that is written back must end up in the shape the load seam + * would produce, or the seam keeps re-deriving it on every boot and the + * migration was pointless. But `FlowSchema.parse` also materializes defaults + * (`version`, `runAs`, per-edge `type` / `isDefault`), and persisting a default + * the author never wrote pins that row to today's value forever — so the graft + * is deliberately narrow: it copies the lowered `condition`, nothing more. + * + * Structural alignment is by position, which is sound because neither the parse + * nor `normalizeControlFlowRegions` reorders or drops array members — both are + * copy-on-write maps. Where the two sides disagree in shape (a caller passed a + * mismatched pair), the converted side is returned untouched: this only ever + * lifts a value it can positively match. + * + * Node `config.condition` (e.g. a start node's record-change predicate) is + * left alone by construction — `FlowNodeSchema.config` is an open `z.record`, + * so the parse never lowers it, so there is no envelope on the parsed side to + * copy and the recursion finds a string facing a string. + */ +function graftConditionEnvelopes(converted: unknown, parsed: unknown): unknown { + if (Array.isArray(converted)) { + if (!Array.isArray(parsed)) return converted; + let changed = false; + const out = converted.map((entry, i) => { + const next = graftConditionEnvelopes(entry, parsed[i]); + if (next !== entry) changed = true; + return next; + }); + return changed ? out : converted; + } + if ( + converted && typeof converted === 'object' + && parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ) { + const parsedRec = parsed as Record; + let changed = false; + const out: Record = {}; + for (const [key, value] of Object.entries(converted as Record)) { + if (key === 'condition' && typeof value === 'string') { + const lowered = parsedRec[key]; + if ( + lowered && typeof lowered === 'object' && !Array.isArray(lowered) + && typeof (lowered as { source?: unknown }).source === 'string' + ) { + out[key] = lowered; + changed = true; + continue; + } + } + const next = graftConditionEnvelopes(value, parsedRec[key]); + if (next !== value) changed = true; + out[key] = next; + } + return changed ? out : converted; + } + return converted; +} + export class AutomationEngine implements IAutomationService { /** * ADR-0044: maximum times a single node may be (re-)entered at the top @@ -973,9 +1101,13 @@ export class AutomationEngine implements IAutomationService { /** * Persist a suspended run to the in-memory cache and (best-effort) the - * durable store. A store failure is logged but does not fail the run — the - * in-memory copy still allows in-process resume; only cross-restart - * durability is lost. + * durable store. A store failure does not fail the run — the in-memory copy + * still allows in-process resume; only cross-restart durability is lost. + * + * Logged at ERROR, not warn: a durable pause that silently stayed + * in-memory is data-loss-in-waiting. #4420 was exactly this — a store + * pointed at a table that was never created, every save failing into a warn + * nobody read, and every in-flight approval zombified by the next restart. */ private async persistSuspendedRun(run: SuspendedRun): Promise { this.suspendedRuns.set(run.runId, run); @@ -983,8 +1115,8 @@ export class AutomationEngine implements IAutomationService { try { await this.store.save(run); } catch (err) { - this.logger.warn( - `[automation] failed to persist suspended run '${run.runId}' to durable store (kept in memory only): ${(err as Error).message}`, + this.logger.error( + `[automation] failed to persist suspended run '${run.runId}' to the durable store — it is kept in memory only and will NOT be resumable after a restart: ${(err as Error).message}`, ); } } @@ -1548,7 +1680,37 @@ export class AutomationEngine implements IAutomationService { // ── IAutomationService Contract Implementation ──────── - registerFlow(name: string, definition: unknown): void { + /** + * Canonicalize a flow definition the way the load seam does — the ONE + * policy, exposed so a caller that is not registering the flow can still + * ask "what is this flow's canonical shape?" (#4454). + * + * Two consumers, two shapes, one pass — because they share every expensive + * step and must never drift apart: + * + * - `parsed` is for **execution**: `FlowSchema.parse` output with the + * region pass applied, i.e. schema defaults materialized. This is what + * {@link registerFlow} runs and stores in `this.flows`. + * - `storable` is for **persistence** (`os migrate meta --stored`, #4327): + * the conversion output plus the `condition` envelopes the schema lowers, + * and *deliberately nothing else*. Schema defaults (`version`, `runAs`, + * per-edge `type` / `isDefault`) are excluded on purpose — writing values + * the author never wrote would freeze every migrated row on today's + * defaults while untouched rows follow tomorrow's, i.e. two populations + * with different behaviour. That is exactly the drift a canonicalization + * pass exists to remove, so the pass must not become a source of it. + * + * Throws whatever the parse throws. `FlowSchema` is **strict** (#4001), so + * a flow carrying an unrecognized key is a hard error here rather than a + * silent drop; a caller migrating stored rows reports that row as failed + * instead of persisting a guess. + */ + canonicalizeStoredFlow(name: string, definition: unknown): { + parsed: FlowParsed; + storable: unknown; + notices: ConversionNotice[]; + conflicts: ConversionConflictNotice[]; + } { // ADR-0087 D2 — the runtime load seam. A stored flow authored against an // old shape (a `webhook`/`http_request` callout node, a `delete_record` // with `config.filters`) is canonicalized on rehydration, BEFORE parse + @@ -1573,11 +1735,19 @@ export class AutomationEngine implements IAutomationService { ...this.nodeExecutors.keys(), ...this.actionDescriptors.keys(), ]); + const notices: ConversionNotice[] = []; + const conflicts: ConversionConflictNotice[] = []; const converted = applyConversionsToFlow(definition, { reservedNodeTypes, includeRetired: true, - onNotice: (n) => this.logger.warn(`[flow '${name}'] ${n.code}: ${n.message}`), - onConflict: (c) => this.logger.warn(`[flow '${name}'] ${c.code}: ${c.message}`), + onNotice: (n) => { + notices.push(n); + this.logger.warn(`[flow '${name}'] ${n.code}: ${n.message}`); + }, + onConflict: (c) => { + conflicts.push(c); + this.logger.warn(`[flow '${name}'] ${c.code}: ${c.message}`); + }, }); const flowShell = FlowSchema.parse(converted); @@ -1599,6 +1769,20 @@ export class AutomationEngine implements IAutomationService { // still reported by the validator that owns that message. const parsed = normalizeControlFlowRegions(flowShell); + return { + parsed, + storable: graftConditionEnvelopes(converted, parsed), + notices, + conflicts, + }; + } + + registerFlow(name: string, definition: unknown): void { + // One canonicalization policy, shared with the stored-row migration so + // the two can never disagree about what "canonical" means (#4454). + // Execution takes the parsed shape (schema defaults materialized). + const { parsed } = this.canonicalizeStoredFlow(name, definition); + // ADR-0018 §M1 — validate node types against the live action registry. // The protocol no longer gates `type` with a closed enum; membership is // checked here instead. Soft-fail (warn, don't throw): a flow authored @@ -2374,13 +2558,16 @@ export class AutomationEngine implements IAutomationService { } /** Read a suspended run from the hot cache, falling back to the durable - * store. Read-only — never consumes the suspension. */ + * store. Read-only — never consumes the suspension. + * + * Degrading form: a store read failure becomes `null`, i.e. "no such run". + * Correct for the incidental readers (a gate lookup, a screen fetch) that + * only need a best-effort answer. NOT correct for {@link resumeInternal}, + * which must tell a dead run from an unreachable store — it uses + * {@link loadSuspendedRunStrict}. */ private async loadSuspendedRun(runId: string): Promise { - const cached = this.suspendedRuns.get(runId); - if (cached) return cached; - if (!this.store) return null; try { - return await this.store.load(runId); + return await this.loadSuspendedRunStrict(runId); } catch (err) { this.logger.warn( `[automation] failed to load suspended run '${runId}' from durable store: ${(err as Error).message}`, @@ -2389,6 +2576,32 @@ export class AutomationEngine implements IAutomationService { } } + /** {@link loadSuspendedRun} without the degradation: a store read failure + * THROWS instead of reading as "no such run". */ + private async loadSuspendedRunStrict(runId: string): Promise { + const cached = this.suspendedRuns.get(runId); + if (cached) return cached; + if (!this.store) return null; + return await this.store.load(runId); + } + + /** + * Whether a suspension exists for `runId`, in the hot cache or the durable + * store. Read-only — never consumes the suspension. + * + * For callers that must know a run is resumable BEFORE they write anything + * of their own: approvals pre-flights this so a decision is never recorded + * against a run that can no longer advance (#4420). + * + * THROWS when the durable store cannot be read — an outage means "unknown", + * and a caller must not act on it as if the run were gone. Contrast + * {@link getRun}, which reports on the execution LOG and returns null for a + * run suspended by a previous process even when its state is durable. + */ + async hasSuspendedRun(runId: string): Promise { + return (await this.loadSuspendedRunStrict(runId)) !== null; + } + /** * Credit a completed child run's totals to the parent step waiting on it * (#4354). @@ -2444,15 +2657,33 @@ export class AutomationEngine implements IAutomationService { // twice. A duplicate that arrives *after* this one finishes finds no // suspended run and returns the "no suspended run" error below. if (this.resuming.has(runId)) { - return { success: false, error: `Run '${runId}' is already being resumed` }; + return { success: false, code: 'RESUME_IN_PROGRESS', error: `Run '${runId}' is already being resumed` }; } this.resuming.add(runId); try { // Hot path: suspended in this process. Cold path: rehydrate from the // durable store (e.g. the process restarted since the pause, ADR-0019). - const run = await this.loadSuspendedRun(runId); + // + // Strict load: a store that cannot be READ must not report as + // "no such run" (#4420). A caller that already persisted a decision + // needs "retry when the store is back" to be distinguishable from + // "this run is gone for good" — same failure, opposite remedy. + let run: SuspendedRun | null; + try { + run = await this.loadSuspendedRunStrict(runId); + } catch (err) { + const message = (err as Error).message; + this.logger.error( + `[automation] durable suspended-run store unreachable while resuming '${runId}': ${message}`, + ); + return { + success: false, + code: 'STORE_UNAVAILABLE', + error: `Durable suspended-run store unreachable for run '${runId}' — retry once the store is available: ${message}`, + }; + } if (!run) { - return { success: false, error: `No suspended run '${runId}'` }; + return { success: false, code: 'RUN_NOT_FOUND', error: `No suspended run '${runId}'` }; } const flow = this.flows.get(run.flowName); if (!flow) { @@ -2517,6 +2748,17 @@ export class AutomationEngine implements IAutomationService { } } + // The SCREEN contract (#4477). A run parked on a `screen` node + // declared exactly which keys it collects and which are required; + // until this ran, `resume` folded any bag at all straight into the + // variables, so a caller that skipped the dialog bypassed every + // `required` the author wrote. Checked here — beside the engine's + // other resume refusals and BEFORE the suspension is consumed — so + // a rejected bag leaves the pause live and the legitimate + // submission still lands. + const screenRefusal = this.refuseInvalidScreenInput(run, runId, signal); + if (screenRefusal) return screenRefusal; + // Restore the variable context and fold the signal in — the ONE // place a resume signal reaches the variable map. Runs BEFORE the // suspension is consumed, so a rejected signal changes nothing: @@ -2673,6 +2915,89 @@ export class AutomationEngine implements IAutomationService { } } + /** + * Enforce a suspended `screen` node's declared field contract against the + * submitted bag, returning a refusal or `null` to allow (#4477). + * + * The render half of `screen` always worked — the trigger response and + * `GET …/runs/:runId/screen` carry `required` and `visibleWhen` intact, so + * a renderer had everything it needed. There was no validation half: + * `resume` accepted `{}` on a screen with an unconditional `required` + * field, accepted a visible conditional field's value being absent, and + * accepted keys the screen never declared — every one of them completing + * the run. A client that skipped the dialog and posted here directly was + * unconstrained by anything the flow author wrote. + * + * Scope, and the reasons for each edge: + * + * - **Only `signal.variables`.** That is the screen's collected-values + * channel (the executor surfaces `fields`, the runner posts `inputs`). + * `signal.output` is the node-OUTPUT namespace, lands under + * `${nodeId}.${key}`, and belongs to the approval-style resume envelope + * — a different contract, not this one's to police. + * - **Only a screen that declares fields** — see + * {@link screenDeclaresInputContract}. An object-form screen and a + * message-only screen declare no keys, so they constrain none (the same + * pass-through `enforceActionParams` gives a param-less action). + * - **Never an engine-built signal.** The subflow output mapping and the + * `map` item handoff are the engine's own continuations; they carry + * author-named output variables, not a screen submission. + * + * `visibleWhen` is evaluated against the SUBMITTED values first (layered + * over the run's variables, so a predicate may reference a prior node), + * because a hidden field's `required` must not fire — that is #3528's + * dead-end reproduced server-side. An unevaluable predicate is reported and + * treated as hidden: the client decides what the user saw, and a broken + * predicate is not evidence a field was shown. + */ + private refuseInvalidScreenInput( + run: SuspendedRun, + runId: string, + signal: ResumeSignal | undefined, + ): AutomationResult | null { + if (!signal) return null; + if ((signal as Record)[ENGINE_BUILT_SIGNAL] === true) return null; + if (!screenDeclaresInputContract(run.screen)) return null; + const fields = run.screen!.fields; + + const bag = (signal.variables ?? {}) as Record; + // Submitted values win over the snapshot: the predicate is about what + // the user is filling in NOW, and the run's variables only supply the + // wider context a `visibleWhen` may legitimately reference. + const scope = new Map(Object.entries(run.variables)); + for (const [k, v] of Object.entries(bag)) scope.set(k, v); + + const visibility = (field: ScreenFieldSpec): ScreenFieldVisibility => { + try { + return this.evaluateCondition(String(field.visibleWhen), scope); + } catch (err) { + this.logger.warn( + `[automation] run '${runId}': screen field '${field.name}' has a visibleWhen that could not be ` + + `evaluated (\`${field.visibleWhen}\`: ${(err as Error)?.message}) — its \`required\` is not ` + + `enforced for this submission`, + ); + return undefined; + } + }; + + const issues = validateScreenInputs(fields, bag, visibility); + if (!issues.length) return null; + + const declared = declaredScreenFieldNames(fields); + const summary = issues.map((i) => i.message).join('; '); + this.logger.warn( + `[automation] refused resume of run '${runId}': screen '${run.nodeId}' input violates its declared ` + + `field contract — ${summary}`, + ); + return { + success: false, + code: 'INVALID_SCREEN_INPUT', + error: + `Invalid screen input: ${summary} — declared fields: ` + + `${declared.map((n) => `'${n}'`).join(', ') || '(none)'}`, + }; + } + /** * Build the resume signal that maps a completed subflow child's output * into its parent — mirroring the synchronous path exactly: the engine's @@ -3599,10 +3924,37 @@ export class AutomationEngine implements IAutomationService { * {@link executeNode} so {@link resume} can re-enter traversal from a * suspended node without re-running the node body. * - * @param branchLabel - When set (e.g. from a resume signal), restrict - * traversal to out-edges whose `label` matches — this is how an Approval - * node's `approve`/`reject` decision selects its downstream branch. When - * no edge carries the label, traversal falls back to the normal edge set. + * Three declared mechanisms select a branch here, and #4414 found two of + * them doing nothing. They now compose as ONE model, applied in this order: + * + * 1. **`branchLabel`** (from a `decision`/`approval` executor or a resume + * signal) narrows the edge set to out-edges carrying that `label`. + * {@link DEFAULT_BRANCH_LABEL} is the engine's own sentinel for "the + * node's declared conditions all failed" and is additionally claimed by + * the BPMN default edge. A label NO edge claims is a metadata error — + * traversal still falls back to the full edge set (a run mid-flight must + * not die on it) but it is now **logged**, not silent: the decision had + * computed a branch and nothing routed it, which is how app-crm's + * convert-lead guard ran its abort screen AND its wizard. + * 2. **`edge.condition`** — evaluated per edge; a closed gate records a + * `skipped` step (#4354). + * 3. **`edge.isDefault`** — BPMN default flow. Traversed **only** when no + * conditional sibling in the selected set matched. Before #4414 this key + * had zero readers: it parsed, it was documented as "the default path + * when no other conditions match", and it routed nothing — an author who + * reached for it got an ordinary unconditional edge that ran on every + * pass, in parallel with the branch that *did* match. + * + * A default edge is therefore NOT part of the unconditional parallel fan-out + * — that distinction is the whole point of the marker. An edge that carries + * both a `condition` and `isDefault` is self-contradictory (BPMN forbids it); + * the `condition` wins here, and the flow linter flags the shape at authoring + * time (`flow-default-edge-with-condition`) so it is caught before it runs — + * Prime Directive #12. + * + * @param branchLabel - When set, restrict traversal to out-edges whose + * `label` matches — this is how an Approval node's `approve`/`reject` + * decision selects its downstream branch. */ private async traverseNext( node: FlowNodeParsed, @@ -3612,31 +3964,61 @@ export class AutomationEngine implements IAutomationService { steps: StepLogEntry[], branchLabel?: string, ): Promise { - // Find next nodes — separate conditional and unconditional edges - let outEdges = flow.edges.filter( + // Find next nodes — separate conditional, default and unconditional edges + const allOutEdges = flow.edges.filter( e => e.source === node.id && e.type !== 'fault', ); + let outEdges = allOutEdges; - // Branch selection (resume): prefer edges tagged with the decision label. + // Branch selection: prefer edges tagged with the decision label. if (branchLabel) { - const labeled = outEdges.filter(e => e.label === branchLabel); - if (labeled.length > 0) outEdges = labeled; + let claimed = outEdges.filter(e => e.label === branchLabel); + // The `default` sentinel is also claimed by the BPMN default edge, so + // "none of my conditions matched" routes to the declared fallback + // without the author having to ALSO label that edge 'default'. + if (claimed.length === 0 && branchLabel === DEFAULT_BRANCH_LABEL) { + claimed = outEdges.filter(e => e.isDefault); + } + if (claimed.length > 0) { + outEdges = claimed; + } else { + // #4414 — do not fall back silently. The node computed a branch + // and no out-edge claims it, so every out-edge is about to be + // considered: the guard the author wrote is not guarding. + const declared = allOutEdges + .map(e => (e.label ? `'${e.label}'` : `(unlabelled ${e.id})`)) + .join(', '); + this.logger.warn( + // `flow.name` is absent on the synthetic view `runRegion` builds. + `Flow '${flow.name ?? '(region)'}' node '${node.id}' (${node.type}) selected branch ` + + `'${branchLabel}', but no out-edge carries that label — out-edge labels are ` + + `[${declared || 'none'}]. The branch selection is IGNORED and every out-edge is ` + + `evaluated instead, so unconditional siblings run regardless of the decision. ` + + `Make an out-edge's \`label\` match the branch, or mark the fallback edge ` + + `\`isDefault: true\`. (#4414)`, + ); + } } const conditionalEdges: FlowEdgeParsed[] = []; + const defaultEdges: FlowEdgeParsed[] = []; const unconditionalEdges: FlowEdgeParsed[] = []; for (const edge of outEdges) { if (edge.condition) { conditionalEdges.push(edge); + } else if (edge.isDefault) { + defaultEdges.push(edge); } else { unconditionalEdges.push(edge); } } // Conditional edges: evaluate sequentially (mutually exclusive) + let anyConditionMet = false; for (const edge of conditionalEdges) { const nextNode = flow.nodes.find(n => n.id === edge.target); if (this.evaluateCondition(edge.condition!, variables)) { + anyConditionMet = true; if (nextNode) { await this.executeNode(nextNode, flow, variables, context, steps); } @@ -3670,6 +4052,38 @@ export class AutomationEngine implements IAutomationService { } } + // Default edges (BPMN default flow, #4414): the fallback, taken only + // when NO conditional sibling matched. `isDefault` is what makes this an + // "otherwise" rather than a second unconditional path — without it the + // author's only spelling of "otherwise" was to hand-write the negation + // of every sibling condition, and forgetting to do that ran both + // branches. A default edge passed over because a real branch won records + // the same `skipped` trace a closed gate does (#4354). + for (const edge of defaultEdges) { + const nextNode = flow.nodes.find(n => n.id === edge.target); + if (!anyConditionMet) { + if (nextNode) { + await this.executeNode(nextNode, flow, variables, context, steps); + } + } else if (nextNode) { + const at = new Date().toISOString(); + steps.push({ + nodeId: nextNode.id, + nodeType: nextNode.type, + ...(nextNode.label ? { nodeLabel: nextNode.label } : {}), + status: 'skipped', + startedAt: at, + completedAt: at, + durationMs: 0, + skippedBy: { + nodeId: node.id, + ...(edge.id ? { edgeId: edge.id } : {}), + ...(edge.label ? { label: edge.label } : {}), + }, + }); + } + } + // Unconditional edges: execute in parallel (Promise.all) if (unconditionalEdges.length > 0) { const parallelTasks = unconditionalEdges @@ -3762,15 +4176,42 @@ export class AutomationEngine implements IAutomationService { } /** - * Safe expression evaluator. - * Uses simple operator-based parsing without `new Function`. - * Supports: comparisons (>, <, >=, <=, ==, !=, ===, !==), - * boolean literals (true, false), and basic arithmetic. + * Evaluate a flow condition to a boolean. + * + * ## Which dialect a condition is in + * + * A condition is **CEL** unless it is written in the legacy single-brace + * `{var}` template dialect — and that is decided by looking at the *source*, + * not at whether an envelope happens to be present (#4336). + * + * It used to be decided by the envelope, and that was the bug: only an + * `{ dialect, source }` envelope reached the CEL engine, so a condition + * authored as a plain string — the shape `ExpressionInput` accepts by design, + * and the shape every node `config` still holds, since `FlowNodeSchema.config` + * is an open `z.record` no transform can reach — fell through to the template + * path and was compared **as text**. The failure direction depended on the + * predicate, which is what made it dangerous: + * + * 'existingTask == null' → 'existingTask' === 'null' → always FALSE + * 'record.rating >= 4' → 'record.rating' >= '4' → always TRUE + * + * — one gate that never opens, one branch pinned open, both reporting + * `success`. Reading the source instead means the same predicate evaluates + * the same way wherever it is authored: an edge (parsed into an envelope by + * `FlowEdgeSchema`), a start-node gate, or a `decision` node's + * `config.conditions[].expression`, which no schema normalizes. + * + * The `{var}` dialect stays supported for the flows that use it — but it no + * longer answers `false` when it could not resolve something. Per ADR-0032 + * §1c a predicate that cannot be evaluated is a **fault**, never a quiet + * branch decision, so an unresolved hole is refused with the source attached. + * + * Braces inside a **CEL envelope** remain the #1491 brace-trap and still + * throw: an explicit `dialect: 'cel'` is the author saying "this is CEL", and + * `{…}` is a map literal there. The sniff only applies where the dialect was + * never stated. */ evaluateCondition(expression: string | { dialect?: string; source?: string; ast?: unknown }, variables: Map): boolean { - // M9.5+ wiring: route Expression envelopes through @objectstack/formula - // ExpressionEngine. CEL is the default; legacy `{var}` template syntax - // is preserved as a fallback for back-compat. const isEnvelope = typeof expression === 'object' && expression != null && 'dialect' in expression; const dialect = isEnvelope ? (expression as { dialect?: string }).dialect : undefined; const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? ''); @@ -3780,9 +4221,23 @@ export class AutomationEngine implements IAutomationService { return false; } + // An absent / empty condition is not a predicate to evaluate. Callers that + // mean "unconditional" guard before calling; this is the one that does not + // (a `decision` node whose `conditions[]` entry has no `expression`), and + // an unauthored branch must not open. + if (exprStr.trim() === '') return false; + + // The dialect decision (see the doc comment). An explicit `template`/`flow` + // envelope takes the author at their word; a bare string is sniffed for a + // `{var}` hole; everything else — including an envelope with no dialect — + // is CEL. + const holes = templateHoles(exprStr); + const declaredTemplate = isEnvelope && (dialect === 'template' || dialect === 'flow'); + const useTemplateDialect = declaredTemplate || (!isEnvelope && holes.length > 0); + // CEL path — bind `vars` scope for `{step.result}` style references via // the equivalent `vars.step.result` CEL identifier path. - if (dialect === 'cel' || (isEnvelope && !dialect)) { + if (!useTemplateDialect) { try { const vars: Record = {}; for (const [key, value] of variables) { @@ -3838,11 +4293,15 @@ export class AutomationEngine implements IAutomationService { // No `try { … } catch { return false }` around this block (#4347). Nothing // in it throws — `indexOf` / `slice` / `Number` / `compareValues` are all - // total — so the catch guarded nothing, and the one thing that CAN throw - // here now is the deliberate refusal below, which a swallow-to-`false` - // would turn straight back into the silent wrong answer it exists to + // total — so the catch guarded nothing, and the things that CAN throw + // here now are the deliberate refusals below, which a swallow-to-`false` + // would turn straight back into the silent wrong answer they exist to // prevent (ADR-0032 §1c, same rule as the CEL path above). + // A hole naming nothing in the variable map is unresolvable (#4336). + // Refuse — see the helper for why `false` was the wrong answer. + this.refuseUnresolvedTemplateHole(exprStr, holes.filter(h => !variables.has(h.slice(1, -1)))); + // Boolean literals if (resolved === 'true') return true; if (resolved === 'false') return false; @@ -3863,7 +4322,57 @@ export class AutomationEngine implements IAutomationService { const numVal = Number(resolved); if (!isNaN(numVal)) return numVal !== 0; - return false; + // No operator, not a boolean, not a number — this path has no way to + // decide the branch, and `false` used to be its answer (#4336). That made + // a truthy gate on a non-boolean variable — `'{record.status}'`, where + // the value is `'open'` — read as "condition not met" forever, with the + // run still recorded as `success`. Refuse instead, same rule as above. + throw new Error( + `condition evaluation error: \`${resolved}\` is not a predicate — source: \`${exprStr}\`. ` + + `The legacy \`{var}\` template dialect decides a branch by comparing the substituted ` + + `text, so it needs a comparison (\`{status} == 'open'\`) or a value that reads as a ` + + `boolean or number; a bare non-boolean value gives it nothing to compare and used to ` + + `answer \`false\` regardless of the value. Write the predicate as CEL — a condition ` + + `without \`{…}\` braces is evaluated by the CEL engine, where \`record.isActive\` is a ` + + `truthy gate and \`record.status == 'open'\` resolves the field.`, + ); + } + + /** + * Refuse a legacy-dialect condition whose `{…}` holes name no variable + * (#4336). + * + * Substitution replaces the literal text `{}` for each key in the + * variable map, so an unmatched hole survives into the comparison — and the + * template path would then compare the *brace text itself*: + * + * '{lead_record.status} == \'converted\'' + * → '{lead_record.status}' === "'converted'" → always FALSE + * + * The gate never opens, for any record, and the run still reports `success`. + * + * The common way to land here is a **field access on an object variable**: + * `get_record`'s `outputVariable` stores the whole record under one name + * (`lead_record`), so `{lead_record.status}` asks for a key that was never + * written. Note the asymmetry that let this survive — a node's outputs ARE + * flattened into dotted keys (`${node.id}.${key}`), so `{get_lead.id}` + * resolves and looks like proof the spelling works. + * + * CEL resolves that access properly, which is why the prescription is to drop + * the braces rather than to spell the hole differently. + */ + private refuseUnresolvedTemplateHole(source: string, unresolved: readonly string[]): void { + if (unresolved.length === 0) return; + const names = [...new Set(unresolved)]; + throw new Error( + `condition evaluation error: ${names.map(h => `\`${h}\``).join(', ')} did not resolve — ` + + `source: \`${source}\`. The legacy \`{var}\` template dialect substitutes a WHOLE flow ` + + `variable by name, and no variable is named ${names.map(h => `\`${h.slice(1, -1)}\``).join(', ')}. ` + + `Leaving it in place would compare the brace text as a STRING — a branch that is silently ` + + `wrong rather than merely unevaluated — so this is refused. Drop the braces: a condition ` + + `without them is evaluated as CEL, which resolves field access on an object variable ` + + `(\`lead_record.status == 'converted'\`) instead of looking for a variable spelled that way.`, + ); } /** @@ -3885,6 +4394,12 @@ export class AutomationEngine implements IAutomationService { * predicate that cannot be evaluated is a fault, never a quiet `false` (or, * here, a quiet `true`). * + * Since #4336 a *wholly* brace-free condition no longer arrives here at all — + * it is CEL, and `oppRecord.amount > 500000` simply evaluates. What still + * reaches this guard is a dotted reference **mixed into** a template-dialect + * condition (`'{limit} > record.amount'`), where the author is one operand + * away from the right dialect and the string compare would answer anyway. + * * Only *dotted* references are refused. A bare word compares as a string on * purpose — `'{status} == active'` is the documented legacy spelling, and * after substitution both sides are plain words. @@ -3906,8 +4421,19 @@ export class AutomationEngine implements IAutomationService { /** * Compare two string-represented values with an operator. + * + * Quoted operands are unquoted first (#4336). The template dialect compares + * text, so `'{status} == \'active\''` used to substitute to `active == + * 'active'` and compare `active` against `'active'` **with the quotes** — + * never equal, for any value of `status`. That spelling is not exotic: it is + * what the flow docs show for a decision node, and quoting a string literal + * is what every other predicate surface on the platform requires. So the + * quotes are stripped and both the quoted and the bare form (`{status} == + * active`, the older documented spelling) compare the same way. */ private compareValues(left: string, op: string, right: string): boolean { + left = unquoteLiteral(left); + right = unquoteLiteral(right); const lNum = Number(left); const rNum = Number(right); const bothNumeric = !isNaN(lNum) && !isNaN(rNum) && left !== '' && right !== ''; diff --git a/packages/services/service-automation/src/guard-refusal-inventory.test.ts b/packages/services/service-automation/src/guard-refusal-inventory.test.ts index fb8a21607c..4d4ef1e680 100644 --- a/packages/services/service-automation/src/guard-refusal-inventory.test.ts +++ b/packages/services/service-automation/src/guard-refusal-inventory.test.ts @@ -139,7 +139,10 @@ const GUARDS: Array<{ name: string; why: string; node: Record; name: 'subflow without flowName', why: 'a required config key', node: { type: 'subflow', config: {} }, - expect: 'flowName is required', + // #4343 moved this from a hand-written `refuseNode` to the contract + // parse, like the CRUD entries above. Same classification, same node — + // only the message is now derived from `SubflowConfigSchema`. + expect: 'does not satisfy the subflow contract', }, { name: 'map without flowName', diff --git a/packages/services/service-automation/src/inert-mode.test.ts b/packages/services/service-automation/src/inert-mode.test.ts new file mode 100644 index 0000000000..6e26850deb --- /dev/null +++ b/packages/services/service-automation/src/inert-mode.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4454 — `armRuntime: false`: an engine, and nothing armed. + * + * `os migrate meta --stored` needs this plugin for exactly one read-only thing: + * the live executor registry, so ADR-0078's open-namespace conflict guard can + * tell a flow-node rename from a clobber. It must not get the runtime that + * normally rides along — booting a migration process that arms record triggers, + * fires scheduled jobs, opens connector connections, or resumes a paused + * approval is indefensible. + * + * The two halves are equally load-bearing and are tested as such: nothing is + * armed, AND the registry is complete. A partial registry would not fail + * loudly — it would make the guard read a live custom node type as unowned and + * rewrite over it, which is the exact silent clobber the guard exists to stop. + */ +import { describe, expect, it, vi } from 'vitest'; +import { AutomationServicePlugin } from './plugin.js'; + +/** A minimal PluginContext that records what the plugin did with it. */ +function makeCtx() { + const services = new Map(); + const hooks: string[] = []; + const triggered: string[] = []; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => { + if (services.has(name)) return services.get(name); + throw new Error(`no service ${name}`); + }, + getServices: () => services, + hook: (name: string) => { hooks.push(name); }, + trigger: async (name: string) => { triggered.push(name); }, + }; + return { ctx, services, hooks, triggered }; +} + +async function boot(options: Record) { + const h = makeCtx(); + const plugin = new AutomationServicePlugin(options as any); + await plugin.init(h.ctx); + await plugin.start(h.ctx); + return { ...h, plugin, engine: h.services.get('automation') as any }; +} + +describe('armRuntime: false — nothing is armed (#4454)', () => { + it('registers no flow, so no trigger or schedule is bound', async () => { + const { engine } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); + + expect(await engine.listFlows()).toEqual([]); + // The audit is the engine's own account of what it would fire. + expect(engine.getFlowRuntimeStates()).toEqual([]); + }); + + it('arms none of the runtime lifecycle hooks that would register flows later', async () => { + // Skipping only the boot pull would be a half-measure: `kernel:ready` + // and `metadata:reloaded` both re-register flows, so a long-lived + // inert process would arm them a moment later. + const { hooks } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); + + expect(hooks).not.toContain('kernel:ready'); + expect(hooks).not.toContain('metadata:reloaded'); + }); + + it('still fires automation:ready — a partial registry would corrupt the guard', async () => { + // This is the one thing inert mode must NOT skip. Third-party executors + // register on this hook; without them `reservedNodeTypes` is short, and + // the conflict guard silently rewrites over a live custom node type + // instead of refusing. + const { triggered } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); + + expect(triggered).toContain('automation:ready'); + }); + + it('has the built-in node registry populated, which is what the migration needs', async () => { + const { engine } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); + + const types = engine.getRegisteredNodeTypes(); + expect(types.length).toBeGreaterThan(0); + expect(types).toContain('delete_record'); + // …and the method the migration actually calls works off it. + expect(typeof engine.canonicalizeStoredFlow).toBe('function'); + }); + + it('canonicalizes a stored flow — the whole point of booting it at all', async () => { + const { engine } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); + + const { storable, notices } = engine.canonicalizeStoredFlow('purge', { + name: 'purge', + label: 'Purge', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'n1', type: 'delete_record', label: 'Purge', config: { objectName: 'lead', filters: { status: 'stale' } } }], + edges: [], + }); + + expect((storable as any).nodes[0].config.filter).toEqual({ status: 'stale' }); + expect(notices.some((n: any) => n.from === 'filters')).toBe(true); + // Still nothing registered — canonicalizing is not registering. + expect(await engine.listFlows()).toEqual([]); + }); +}); + +describe('the default is unchanged (#4454)', () => { + it('arms the runtime hooks when armRuntime is not set', async () => { + const { hooks, triggered } = await boot({ suspendedRunStore: 'memory' }); + + expect(triggered).toContain('automation:ready'); + expect(hooks).toContain('kernel:ready'); + expect(hooks).toContain('metadata:reloaded'); + }); + + it('arms them when armRuntime is explicitly true', async () => { + const { hooks } = await boot({ armRuntime: true, suspendedRunStore: 'memory' }); + expect(hooks).toContain('kernel:ready'); + }); +}); diff --git a/packages/services/service-automation/src/nested-region-parity.test.ts b/packages/services/service-automation/src/nested-region-parity.test.ts index 0b0976e71b..73d13c7d88 100644 --- a/packages/services/service-automation/src/nested-region-parity.test.ts +++ b/packages/services/service-automation/src/nested-region-parity.test.ts @@ -350,17 +350,25 @@ describe("#4347 — the legacy `{var}` path refuses an unresolved reference", () let engine: AutomationEngine; beforeEach(() => { engine = new AutomationEngine(silentLogger()); }); - it('refuses a dotted reference instead of comparing it lexicographically', () => { + it('evaluates a wholly brace-free dotted predicate as CEL', () => { const vars = new Map([['oppRecord', { amount: 10 }]]); // The reported footgun: 'oppRecord.amount' > '500000' compares 'o' to '5', - // so this used to be TRUE for every record regardless of the amount. - expect(() => engine.evaluateCondition('oppRecord.amount > 500000', vars)).toThrow(/unresolved expression reference/); - expect(() => engine.evaluateCondition('oppRecord.amount > 500000', vars)).toThrow(/dialect: 'cel'/); + // so this used to be TRUE for every record regardless of the amount. #4347 + // stopped the wrong answer by refusing it; #4336 goes on to give the RIGHT + // one — a condition with no `{…}` hole is CEL, so the amount is compared. + expect(engine.evaluateCondition('oppRecord.amount > 500000', vars)).toBe(false); + expect(engine.evaluateCondition('oppRecord.amount > 5', vars)).toBe(true); }); - it('refuses it on either side of the operator', () => { - const vars = new Map(); - expect(() => engine.evaluateCondition('5 == row.shouldRun', vars)).toThrow(/unresolved expression reference/); + it('still refuses a dotted reference mixed INTO a template-dialect condition', () => { + // A `{…}` hole puts the whole condition in the template dialect, where a + // dotted operand is text and the compare would answer anyway. One operand + // away from the right dialect, so the refusal names the fix. + const vars = new Map([['limit', 5]]); + expect(() => engine.evaluateCondition('{limit} > record.amount', vars)).toThrow(/unresolved expression reference/); + expect(() => engine.evaluateCondition('{limit} > record.amount', vars)).toThrow(/dialect: 'cel'/); + // Either side of the operator. + expect(() => engine.evaluateCondition('{limit} == row.shouldRun', vars)).toThrow(/unresolved expression reference/); }); it('evaluates the same predicate correctly once it is a CEL envelope', () => { diff --git a/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts new file mode 100644 index 0000000000..f35f38c271 --- /dev/null +++ b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Durable suspended-run wiring (#4420). + * + * The persistence itself (#1518) was never the missing piece — the store, the + * `sys_automation_run` object and the cold-boot rehydrate all shipped. What + * failed was the WIRING, because the two halves resolve different services in + * different phases: `init()` registers the object with `manifest`, `start()` + * attaches the store on the strength of `objectql`. Composed ahead of + * ObjectQL, the first half silently lost (warn, continue) while the second + * still succeeded — a durable store writing to a table nobody had created. + * Every pause then failed into a log line nobody read, and every in-flight + * approval died at the next restart while reporting perfect health. + * + * These pin the contract that closes it: the object reaches `manifest`, and a + * store is NEVER attached when it did not. + */ + +import { describe, it, expect } from 'vitest'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { AutomationEngine } from './engine.js'; +import { AutomationServicePlugin } from './plugin.js'; +import type { SuspendedRunStoreEngine } from './suspended-run-store.js'; + +/** Rows keyed by id — stands in for the `sys_automation_run` table. */ +function fakeDataEngine(opts: { failReads?: string } = {}) { + const rows = new Map(); + const engine: SuspendedRunStoreEngine & { rows: Map } = { + rows, + async find(_object, options) { + if (opts.failReads) throw new Error(opts.failReads); + const where = options?.where ?? {}; + return [...rows.values()].filter(r => + Object.entries(where).every(([k, v]) => r[k] === v)); + }, + async insert(_object, data) { rows.set(String(data.id), { ...data }); return data; }, + async update(_object, data, options) { + const id = options?.where?.id ?? data.id; + rows.set(String(id), { ...(rows.get(String(id)) ?? { id }), ...data }); + return rows.get(String(id)); + }, + async delete(_object, options) { rows.delete(String(options?.where?.id)); return true; }, + }; + return engine; +} + +/** Captures what a plugin hands the `manifest` service. */ +function recordingManifest() { + const registered: any[] = []; + return { registered, register(m: any) { registered.push(m); } }; +} + +type LogLine = { level: string; msg: string }; + +function capturingLogger(sink: LogLine[]): any { + const log = (level: string) => (msg: any) => sink.push({ level, msg: String(msg) }); + return { + info: log('info'), warn: log('warn'), error: log('error'), debug: log('debug'), + child() { return capturingLogger(sink); }, + }; +} + +/** + * The slice of `PluginContext` this plugin touches. `services` is mutable so a + * test can add one BETWEEN init() and start() — which is precisely the + * composition shape at issue: ObjectQL initializing after automation. + */ +function pluginCtx(services: Map, logs: LogLine[]): any { + return { + logger: capturingLogger(logs), + getService(name: string) { + if (services.has(name)) return services.get(name); + throw new Error(`Service '${name}' not registered`); + }, + registerService(name: string, svc: unknown) { services.set(name, svc); }, + hook() {}, + async trigger() {}, + }; +} + +/** A flow that parks at a pausing node, so a suspend is observable end to end. */ +const PAUSE_FLOW = { + name: 'needs_approval', + label: 'needs_approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'test_pause', label: 'Pause' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'end' }, + ], +}; + +/** Park a run at a pausing node and report where its state went. */ +async function pauseARun(engine: AutomationEngine) { + engine.registerNodeExecutor({ + type: 'test_pause', + descriptor: defineActionDescriptor({ + type: 'test_pause', version: '1.0.0', name: 'Test Pause', + supportsPause: true, isAsync: true, + }), + async execute() { return { success: true, suspend: true, correlation: 'areq_1' }; }, + }); + engine.registerFlow('needs_approval', PAUSE_FLOW as never); + const paused = await engine.execute('needs_approval'); + expect(paused.status).toBe('paused'); + return paused.runId!; +} + +/** + * Run the plugin's lifecycle over a context whose services appear in a chosen + * phase. + * + * @param manifestPhase - `'init'`: a well-ordered composition. `'start'`: + * ObjectQL initializing AFTER automation, so `manifest` is absent during + * init() but present by start() — the #4420 hole. `'never'`: a host with no + * manifest service at all. + */ +async function runLifecycle(opts: { + manifestPhase: 'init' | 'start' | 'never'; + data?: SuspendedRunStoreEngine | null; + suspendedRunStore?: 'auto' | 'memory'; +}) { + const logs: LogLine[] = []; + const services = new Map(); + const manifest = recordingManifest(); + const ctx = pluginCtx(services, logs); + + // The data engine tracks the same phase as `manifest` (they come from the + // same plugin), except under `'never'` — a host that has an engine but no + // manifest service is exactly the composition that used to end up with a + // store over an unregistered object. + const dataPhase = opts.manifestPhase === 'start' ? 'start' : 'init'; + if (opts.manifestPhase === 'init') services.set('manifest', manifest); + if (opts.data && dataPhase === 'init') services.set('objectql', opts.data); + + const plugin = new AutomationServicePlugin( + opts.suspendedRunStore ? { suspendedRunStore: opts.suspendedRunStore } : {}, + ); + await plugin.init(ctx); + + if (opts.manifestPhase === 'start') services.set('manifest', manifest); + if (opts.data && dataPhase === 'start') services.set('objectql', opts.data); + await plugin.start(ctx); + + return { + manifest, logs, + engine: services.get('automation') as AutomationEngine, + errors: () => logs.filter(l => l.level === 'error').map(l => l.msg).join('\n'), + registeredObjects: () => + manifest.registered.flatMap(m => m.objects ?? []).map((o: any) => o.name), + }; +} + +describe('durable suspended-run wiring (#4420)', () => { + it('registers sys_automation_run and persists a pause in a normal composition', async () => { + const data = fakeDataEngine(); + const h = await runLifecycle({ manifestPhase: 'init', data }); + + expect(h.registeredObjects()).toContain('sys_automation_run'); + + // The proof that matters is not "a store was attached" but "the pause + // reached the table" — the exact step that failed silently in rc.1. + const runId = await pauseARun(h.engine); + expect(data.rows.get(runId)).toMatchObject({ status: 'paused', flow_name: 'needs_approval' }); + expect(h.errors()).toBe(''); + }); + + it('refuses the durable store, loudly, when the object was never registered', async () => { + // A data engine but no manifest, ever: the table can never exist, so a + // store here could only fail every write. + const data = fakeDataEngine(); + const h = await runLifecycle({ manifestPhase: 'never', data }); + + const runId = await pauseARun(h.engine); + expect(data.rows.size, 'no store attached ⇒ nothing written').toBe(0); + expect(runId, 'the run still pauses — in memory, as before').toBeTruthy(); + expect(h.errors()).toMatch(/sys_automation_run was never registered/); + // The degradation is stated in the terms an operator acts on. + expect(h.errors()).toMatch(/will NOT survive a restart/); + }); + + it('recovers registration at start() when manifest arrives late', async () => { + // ObjectQL initializing after automation. ObjectQL syncs schemas in its + // own start(), so an object registered here is still created normally — + // the run is durable rather than sacrificed to plugin order. + const data = fakeDataEngine(); + const h = await runLifecycle({ manifestPhase: 'start', data }); + + expect(h.registeredObjects()).toContain('sys_automation_run'); + const runId = await pauseARun(h.engine); + expect(data.rows.get(runId)).toBeTruthy(); + expect(h.errors()).toBe(''); + }); + + it('reports an unreadable table at boot but still attaches the store', async () => { + // The reporter's `no such table: sys_automation_run`, surfaced ONCE at + // boot instead of once per suspend. It does not gate the store: a probe + // this early also fails for conditions that clear before the first + // pause (a driver registered after bootstrap), and disabling + // persistence over that would cause the very loss this guards against. + const data = fakeDataEngine({ failReads: 'no such table: sys_automation_run' }); + const h = await runLifecycle({ manifestPhase: 'init', data }); + + expect(h.errors()).toMatch(/could not be read at startup/); + expect(h.errors()).toMatch(/no such table: sys_automation_run/); + expect((h.engine as any).store, 'store still attached').toBeTruthy(); + }); + + it('registers nothing and attaches nothing in memory mode', async () => { + const data = fakeDataEngine(); + const h = await runLifecycle({ manifestPhase: 'init', data, suspendedRunStore: 'memory' }); + + // An explicitly ephemeral engine is a legitimate mode, not a + // degradation — it must not register the object nor complain. + expect(h.manifest.registered).toEqual([]); + expect((h.engine as any).store).toBeUndefined(); + expect(h.errors()).toBe(''); + }); + + it('keeps working with no data engine at all', async () => { + const h = await runLifecycle({ manifestPhase: 'init', data: null }); + + // No ObjectQL is an ordinary deployment shape, not a fault: in-memory + // pauses, no error. + expect((h.engine as any).store).toBeUndefined(); + expect(h.errors()).toBe(''); + expect(await pauseARun(h.engine)).toBeTruthy(); + }); + + it('declares ObjectQL as an optional dependency so it is ordered first', () => { + // The composition-level fix (ADR-0116): registration order is not a + // contract, so the plugin states the edge rather than hoping for it. + // Optional, not hard — an engine-less kernel must still boot. + const plugin = new AutomationServicePlugin(); + expect(plugin.optionalDependencies).toContain('com.objectstack.engine.objectql'); + expect(plugin.dependencies).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 5714262ad7..2888086b0d 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -60,6 +60,28 @@ export function parseObjectFieldSchema( export interface AutomationServicePluginOptions { /** Enable debug logging for flow execution */ debug?: boolean; + /** + * Bring up the automation **runtime**, not just the engine. Default `true` + * — every server, dev stack and test host wants this and is unaffected. + * + * Set `false` for a one-shot tool that needs the engine as a *reference* + * rather than a runtime — today that is `os migrate meta --stored` (#4454), + * which needs the live executor registry so ADR-0078's open-namespace + * conflict guard can tell a flow-node rename from a clobber, and needs + * nothing else. Inert mode still installs the built-in nodes and still + * fires `automation:ready` (so third-party executors register and the + * registry is COMPLETE — a partial one would make the guard's verdict + * wrong), then stops before anything is armed: + * + * | Skipped in inert mode | Why it must be | + * |---|---| + * | flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live | + * | declarative connector materialization | opens real connections; an MCP provider spawns a child process | + * | suspended-run wait-timer re-arm | would RESUME someone's paused approval mid-migration | + * + * A migration process must not become a second server. + */ + armRuntime?: boolean; /** * Durable suspended-run persistence (ADR-0019): * - `'auto'` (default): persist to `sys_automation_run` via the ObjectQL @@ -339,6 +361,23 @@ export class AutomationServicePlugin implements Plugin { // Do NOT declare a hard kernel dependency, so this plugin works in environments // where MetadataPlugin is not registered. dependencies: string[] = []; + /** + * ObjectQL provides both the `manifest` service that `init()` registers + * {@link SysAutomationRun} with and the data engine the durable + * suspended-run store writes through — so it must init first (ADR-0116). + * + * Order-if-present, not hard: this plugin genuinely runs without an engine + * (pauses stay in-memory), so `dependencies` would break every engine-less + * composition — the unit suites, the connector plugins, and + * `suspendedRunStore: 'memory'`. + * + * Declared because registration order is not a contract (#4131). Composed + * ahead of ObjectQL, `init()` found no `manifest`, the object was never + * registered and its table never created — while `start()` still enabled a + * durable store that then failed every write into a warn nobody read. The + * pauses looked healthy and died at the next restart (#4420). + */ + optionalDependencies: string[] = ['com.objectstack.engine.objectql']; private engine?: AutomationEngine; private readonly options: AutomationServicePluginOptions; @@ -375,11 +414,48 @@ export class AutomationServicePlugin implements Plugin { /** Serializes reconcile runs — see {@link materializeDeclaredConnectors}. */ private reconcileQueue: Promise = Promise.resolve(); private destroyed = false; + /** + * Whether {@link SysAutomationRun} actually reached the `manifest` service, + * so `start()` can refuse to enable a durable store whose table nobody will + * create. Registration and activation resolve DIFFERENT services in + * DIFFERENT phases (`manifest` at init, `objectql` at start), and #4420 is + * what their disagreement looks like in production. + */ + private runObjectRegistered = false; constructor(options: AutomationServicePluginOptions = {}) { this.options = options; } + /** + * Register {@link SysAutomationRun} with the `manifest` service so the + * suspended-run table migrates like every other `sys_*` object (ADR-0019). + * + * Returns whether it landed. Callers must honour a `false` — a durable + * store attached over an unregistered object writes to a table that does + * not exist (#4420). + */ + private registerRunObject(ctx: PluginContext): boolean { + try { + ctx.getService<{ register(m: unknown): void }>('manifest').register({ + id: 'com.objectstack.service-automation', + name: 'Automation Service', + version: '1.0.0', + type: 'plugin', + scope: 'system', + defaultDatasource: 'cloud', + namespace: 'sys', + objects: [SysAutomationRun], + }); + return true; + } catch (err) { + ctx.logger.warn( + `[Automation] manifest service unavailable; sys_automation_run not registered yet: ${(err as Error).message}`, + ); + return false; + } + } + async init(ctx: PluginContext): Promise { this.engine = new AutomationEngine(ctx.logger, undefined, { maxLogSize: this.options.maxLogSize, @@ -393,22 +469,7 @@ export class AutomationServicePlugin implements Plugin { // like other sys_* tables (ADR-0019). Best-effort: a host without the // manifest service still runs in-memory. Skipped when persistence is off. if ((this.options.suspendedRunStore ?? 'auto') !== 'memory') { - try { - ctx.getService<{ register(m: unknown): void }>('manifest').register({ - id: 'com.objectstack.service-automation', - name: 'Automation Service', - version: '1.0.0', - type: 'plugin', - scope: 'system', - defaultDatasource: 'cloud', - namespace: 'sys', - objects: [SysAutomationRun], - }); - } catch (err) { - ctx.logger.warn( - `[Automation] manifest service unavailable; sys_automation_run not registered (suspended runs stay in-memory): ${(err as Error).message}`, - ); - } + this.runObjectRegistered = this.registerRunObject(ctx); } // Seed the platform's built-in node executors. A bare @@ -439,23 +500,102 @@ export class AutomationServicePlugin implements Plugin { `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(', ') || '(none)'}`, ); + // ── Inert mode (#4454) — an engine, and nothing armed ───────────────── + // A one-shot tool (`os migrate meta --stored`) needs this engine for one + // read-only thing: `reservedNodeTypes`, the live executor registry that + // ADR-0078's open-namespace conflict guard consults to tell a rename + // from a clobber. It does NOT want the runtime this plugin normally + // brings up, and everything below this line arms something: + // + // • the flow pull calls `registerFlow`, which calls + // `activateFlowTrigger` — record triggers and scheduled jobs go live; + // • `materializeDeclaredConnectors` opens real connections (an MCP + // provider spawns a child process); + // • the `metadata:reloaded` / `kernel:ready` hooks re-register flows, + // so a long-lived process would arm them later even if we skipped + // the boot pull; + // • `rearmSuspendedWaitTimers` RESUMES suspended runs — a migration + // that silently continues someone's paused approval is indefensible. + // + // The return is placed AFTER `automation:ready` deliberately: that hook + // is how third-party plugins contribute node executors, and a partial + // registry would make the conflict guard's answer wrong — it would read + // a live custom node type as unowned and rewrite over it. Registry + // population is the one thing inert mode must NOT skip. + if (this.options.armRuntime === false) { + ctx.logger.info( + '[Automation] inert mode (armRuntime: false) — engine and node registry are up; ' + + 'no flow registered, no trigger or schedule armed, no connector materialized, ' + + 'no suspended run resumed.', + ); + return; + } + // Upgrade to durable suspended-run persistence when an ObjectQL engine is // present (ADR-0019). The engine was constructed in init() before // services were wired, so we attach the DB-backed store here. Without an // engine (or with `suspendedRunStore: 'memory'`) the in-memory default // stands — suspended runs simply don't survive a restart. + // + // A store is only attached once its table is known to exist. #4420: + // enabling it on the strength of `objectql` alone gave a store whose + // object was never registered, so every write failed and every pause + // was silently ephemeral. Degrading to memory is a legitimate mode; + // degrading to memory while REPORTING persistence is not. let durableStore: ObjectStoreSuspendedRunStore | null = null; if ((this.options.suspendedRunStore ?? 'auto') !== 'memory') { let dataEngine: SuspendedRunStoreEngine | null = null; try { dataEngine = ctx.getService('objectql'); } catch { try { dataEngine = ctx.getService('data'); } catch { /* none */ } } if (dataEngine && typeof dataEngine.find === 'function' && typeof dataEngine.insert === 'function') { - durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger, { + // A late `manifest` still counts: ObjectQL syncs schemas in its + // own start(), so an object registered here — before that runs — + // is created normally. Only worth an info line as a composition + // smell (`optionalDependencies` should have ordered us after it). + if (!this.runObjectRegistered) { + this.runObjectRegistered = this.registerRunObject(ctx); + if (this.runObjectRegistered) { + ctx.logger.info( + '[Automation] sys_automation_run registered at start() — the manifest service was not available during init()', + ); + } + } + const candidate = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger, { maxTerminalRunsPerFlow: this.options.runHistoryMaxPerFlow ?? DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW, }); - this.engine.setSuspendedRunStore(durableStore); - ctx.logger.info('[Automation] Suspended-run persistence enabled (sys_automation_run)'); + if (!this.runObjectRegistered) { + // Authoritative: an object that reached no manifest is in no + // SchemaRegistry, so nothing will ever create its table. A + // store here could only fail every write. + ctx.logger.error( + '[Automation] durable suspended-run persistence was requested but sys_automation_run was never registered ' + + '(no manifest service at init() or start()) — suspended runs are kept IN MEMORY and will NOT survive a restart. ' + + 'Compose ObjectQLPlugin before AutomationServicePlugin, or set `suspendedRunStore: \'memory\'` to make this explicit.', + ); + } else { + // Advisory only — read the table once so a broken setup is + // visible at BOOT instead of one failed write at a time. + // + // It does NOT gate the store: a probe this early can fail + // for reasons that resolve before the first suspend (a + // driver registered after bootstrap, a datasource still + // connecting). Disabling persistence on that would cause + // the very data loss this guards against, so a failure here + // is reported and the store attached anyway — every + // subsequent write failure is logged at error too. + try { + await candidate.probe(); + } catch (err) { + ctx.logger.error( + `[Automation] sys_automation_run could not be read at startup — if this persists, suspended runs will NOT ` + + `survive a restart. Check that schema sync ran for this datasource: ${(err as Error).message}`, + ); + } + durableStore = candidate; + this.engine.setSuspendedRunStore(durableStore); + ctx.logger.info('[Automation] Suspended-run persistence enabled (sys_automation_run)'); + } } else { ctx.logger.info('[Automation] No ObjectQL engine — suspended runs kept in-memory only'); } diff --git a/packages/services/service-automation/src/resume-authority-gate.test.ts b/packages/services/service-automation/src/resume-authority-gate.test.ts index e61fb1e3e3..03435a882d 100644 --- a/packages/services/service-automation/src/resume-authority-gate.test.ts +++ b/packages/services/service-automation/src/resume-authority-gate.test.ts @@ -173,7 +173,10 @@ describe('resume authorization gate (#3801)', () => { it('reports machine-state errors, not a refusal, for an unknown run', async () => { const result = await engine.resume('run_does_not_exist'); expect(result.success).toBe(false); - expect(result.code).toBeUndefined(); + // Its own code, distinct from the refusals above: a caller that already + // wrote a decision down needs "this run is gone" to be actionable, not + // just a message string (#4420). + expect(result.code).toBe('RUN_NOT_FOUND'); expect(result.error).toContain('No suspended run'); }); diff --git a/packages/services/service-automation/src/screen-input-contract.ts b/packages/services/service-automation/src/screen-input-contract.ts new file mode 100644 index 0000000000..a329b0f604 --- /dev/null +++ b/packages/services/service-automation/src/screen-input-contract.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Screen-input VALUE contract (#4477). + * + * A `screen` node's `config.fields` is a complete input contract — the author + * declares the keys, their `required`-ness, and (via `visibleWhen`) when a + * field is even asked for. Before this module the contract informed the CLIENT + * dialog only: `POST …/runs/:runId/resume` folded whatever bag it was handed + * straight into the flow variables, so a caller that skipped the dialog and + * posted to `resume` directly bypassed every `required` the flow author wrote. + * Missing required fields and undeclared keys alike completed the run. + * + * Screen flows are the one place where the declared field contract is the ONLY + * contract — there is no object schema behind a screen node to catch a bad bag + * downstream. The platform enforces the analogous contract everywhere else this + * seam appears: action params (`validateActionParams`, ADR-0104 D2), record + * writes (ADR-0113), approval `decisionOutputs` (#3447). This is that rule for + * screen resume, deliberately built in the same shape — a PURE check returning + * issues, with the disposition (reject with a 400-worthy refusal) owned by the + * caller. + */ + +import type { ScreenFieldSpec, ScreenSpec } from '@objectstack/spec/contracts'; +import type { FieldErrorCode } from '@objectstack/spec/api'; + +/** One violation of a screen's declared field contract. */ +export interface ScreenInputIssue { + /** The offending key — a declared field name, or the undeclared key sent. */ + field: string; + /** + * Which constraint the bag violated, from the field-level catalog + * (ADR-0114 D2) — `required` and `unknown_field`. Typed as `FieldErrorCode` + * rather than a local literal union for the reason `ActionParamIssue` gives: + * a screen field, an action param and a record column must not drift into + * three vocabularies for the same two conditions. + * + * NOT an `error.code` (ADR-0112 D1). These are FIELD-ADDRESSED validator + * codes that ride inside the refusal's message; the refusal's own machine + * code is the SCREAMING `INVALID_SCREEN_INPUT` the engine returns. + */ + code: FieldErrorCode; + message: string; +} + +/** + * Visibility verdict for one conditional field. `true`/`false` are answers; + * `undefined` means the predicate could not be evaluated at all. + */ +export type ScreenFieldVisibility = boolean | undefined; + +function isPresent(v: unknown): boolean { + return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); +} + +/** + * Whether a screen surfaces a field contract worth enforcing. + * + * Two screens deliberately declare NOTHING and so keep the historical + * pass-through, mirroring `enforceActionParams`' "an action with no `params` + * is untouched": + * + * - an **object-form** screen (`kind: 'object-form'`) — its `fields` is empty + * by construction because the CLIENT renders the object's own form, persists + * the record through the normal write path (which enforces the object's + * `required` fields itself, ADR-0113) and resumes with only the saved id + * bound to `idVariable`. There is no flat field list to validate against, + * and validating the bag against `[]` would reject that id as undeclared. + * - a **message-only** screen (`waitForInput: true`, no fields) — a + * confirmation step. It declares no keys, so it constrains none. + */ +export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boolean { + if (!screen) return false; + if (screen.kind === 'object-form') return false; + return Array.isArray(screen.fields) && screen.fields.length > 0; +} + +/** + * Validate a submitted screen bag against the suspended node's declared fields. + * Returns the list of issues (empty ⇒ conformant). Does NOT throw. + * + * Enforced, and nothing beyond it: + * - `required` presence for every field the caller was actually asked for; + * - undeclared keys. + * + * `visibleWhen` is resolved FIRST, by the caller-supplied {@link visibility} + * probe, because a hidden field's `required` must not fire: the client never + * showed it, so demanding it would dead-end the run at Submit — the exact + * failure #3528 filed. A field whose predicate cannot be evaluated is treated + * as hidden (its `required` is not enforced) rather than visible: the client is + * the authority on what the user was shown, and an unevaluable predicate is not + * evidence the field was on screen. It is reported to the caller so the + * degradation is loud rather than silent. Its KEY stays accepted either way — + * the author declared it, so it is never "undeclared". + * + * Value SHAPE (`type`) is out of scope here: a screen field's `type` is a + * widget hint with no closed vocabulary, unlike an action param's field type. + */ +export function validateScreenInputs( + fields: readonly ScreenFieldSpec[], + bag: Record, + visibility: (field: ScreenFieldSpec) => ScreenFieldVisibility, +): ScreenInputIssue[] { + const issues: ScreenInputIssue[] = []; + const declared = new Map(); + for (const f of fields) if (f?.name) declared.set(f.name, f); + + for (const field of declared.values()) { + if (field.required !== true) continue; + if (isPresent(bag[field.name])) continue; + // Conditional field: only a predicate that evaluates TRUE makes `required` + // fire. `false` (hidden — not asked for) and `undefined` (unevaluable) both + // leave it alone. + if (field.visibleWhen != null && String(field.visibleWhen).trim() !== '') { + if (visibility(field) !== true) continue; + } + issues.push({ + field: field.name, + code: 'required', + message: `Screen field "${field.name}" is required`, + }); + } + + for (const key of Object.keys(bag)) { + if (declared.has(key)) continue; + issues.push({ + field: key, + code: 'unknown_field', + message: `Unknown screen field "${key}" — not declared on this screen`, + }); + } + + return issues; +} + +/** + * The declared field names, for an error that tells a caller what it MAY send — + * the same courtesy `decisionOutputs` already extends (#3447), and the + * difference between a rejection an agent can self-correct from and one it + * can only guess at. + */ +export function declaredScreenFieldNames(fields: readonly ScreenFieldSpec[]): string[] { + return fields.map((f) => f?.name).filter((n): n is string => typeof n === 'string' && n.length > 0); +} diff --git a/packages/services/service-automation/src/suspended-run-store.test.ts b/packages/services/service-automation/src/suspended-run-store.test.ts index fc4a006e22..0d84005cad 100644 --- a/packages/services/service-automation/src/suspended-run-store.test.ts +++ b/packages/services/service-automation/src/suspended-run-store.test.ts @@ -164,6 +164,157 @@ describe('ObjectStoreSuspendedRunStore', () => { }); }); +/** A flow that parks at `pause_node`, over an optional store. */ +function pausableEngine(store?: any, logger = createTestLogger()) { + const e = new AutomationEngine(logger, store); + e.registerNodeExecutor({ + type: 'pause_node', + async execute() { return { success: true, suspend: true, correlation: 'areq_1' }; }, + }); + e.registerFlow('approval_flow', { + name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'pause_node', label: 'Approval' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'end' }, + ], + } as never); + return e; +} + +/** + * Resume failure classification (#4420). + * + * A caller that persists its own decision BEFORE resuming — approvals does, + * necessarily — cannot act on a bare `success: false`. "This run is gone for + * good" and "the store is down, try again" need opposite remedies, and a + * duplicate resume is not a failure at all. All three used to read the same, + * and the approvals bridge answered every one of them with HTTP 200. + */ +describe('resume failure codes', () => { + it('reports RUN_NOT_FOUND for a run that does not exist', async () => { + const result = await pausableEngine().resume('run_never_existed'); + expect(result.success).toBe(false); + expect(result.code).toBe('RUN_NOT_FOUND'); + }); + + it('reports STORE_UNAVAILABLE — not RUN_NOT_FOUND — when the store cannot be read', async () => { + const table = createFakeEngine(); + const paused = await pausableEngine(new ObjectStoreSuspendedRunStore(table, createTestLogger())) + .execute('approval_flow'); + + // A second process: nothing cached, and the table is unreachable. The + // run is perfectly alive — reading this as "gone" is what lets a caller + // strand it permanently over a transient outage. + const broken = createFakeEngine(); + broken.find = async () => { throw new Error('connection refused'); }; + const result = await pausableEngine(new ObjectStoreSuspendedRunStore(broken, createTestLogger())) + .resume(paused.runId!); + + expect(result.success).toBe(false); + expect(result.code).toBe('STORE_UNAVAILABLE'); + expect(result.error).toMatch(/retry once the store is available/); + // The suspension is not consumed, so the legitimate resume still lands. + expect(table.rows.get(paused.runId!)?.status).toBe('paused'); + }); + + it('reports RESUME_IN_PROGRESS for a concurrent duplicate resume', async () => { + const e = pausableEngine(); + let release: () => void = () => {}; + const gate = new Promise((r) => { release = r; }); + e.registerNodeExecutor({ + type: 'slow_node', + async execute() { await gate; return { success: true }; }, + }); + e.registerFlow('slow_flow', { + name: 'slow_flow', label: 'Slow Flow', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'pause_node', label: 'Approval' }, + { id: 'slow', type: 'slow_node', label: 'Slow' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'slow' }, + { id: 'e3', source: 'slow', target: 'end' }, + ], + } as never); + const paused = await e.execute('slow_flow'); + + const first = e.resume(paused.runId!); + const duplicate = await e.resume(paused.runId!); + expect(duplicate.success).toBe(false); + expect(duplicate.code).toBe('RESUME_IN_PROGRESS'); + + release(); + expect((await first).success).toBe(true); + }); + + it('logs a failed durable write at ERROR — a pause kept only in memory is data loss in waiting', async () => { + const lines: { level: string; msg: string }[] = []; + const logger: any = { + info: (m: any) => lines.push({ level: 'info', msg: String(m) }), + warn: (m: any) => lines.push({ level: 'warn', msg: String(m) }), + error: (m: any) => lines.push({ level: 'error', msg: String(m) }), + debug: () => {}, + child() { return logger; }, + }; + const broken = createFakeEngine(); + broken.insert = async () => { throw new Error('no such table: sys_automation_run'); }; + const paused = await pausableEngine(new ObjectStoreSuspendedRunStore(broken, logger), logger) + .execute('approval_flow'); + + expect(paused.status).toBe('paused'); // the run still pauses… + const errs = lines.filter(l => l.level === 'error').map(l => l.msg).join('\n'); + expect(errs).toMatch(/no such table: sys_automation_run/); + expect(errs).toMatch(/NOT be resumable after a restart/); + }); +}); + +/** + * `hasSuspendedRun` — the read approvals pre-flights a decision with, so a + * decision is never recorded against a run that can no longer advance. + */ +describe('hasSuspendedRun', () => { + it('sees a run suspended in this process', async () => { + const e = pausableEngine(); + const paused = await e.execute('approval_flow'); + expect(await e.hasSuspendedRun(paused.runId!)).toBe(true); + expect(await e.hasSuspendedRun('run_other')).toBe(false); + }); + + it('sees a run suspended by a PREVIOUS process, off the durable store', async () => { + const table = createFakeEngine(); + const paused = await pausableEngine(new ObjectStoreSuspendedRunStore(table, createTestLogger())) + .execute('approval_flow'); + + // The case `getRun` cannot answer: no execution-log entry exists in + // this process, yet the run is alive and resumable. + const cold = pausableEngine(new ObjectStoreSuspendedRunStore(table, createTestLogger())); + expect(await cold.hasSuspendedRun(paused.runId!)).toBe(true); + expect(await cold.getRun(paused.runId!)).toBeNull(); + }); + + it('throws rather than answering false when the store is unreadable', async () => { + const broken = createFakeEngine(); + broken.find = async () => { throw new Error('connection refused'); }; + const e = pausableEngine(new ObjectStoreSuspendedRunStore(broken, createTestLogger())); + + // "Unknown" must not collapse into "gone" — a caller that treats an + // outage as a dead run rejects every decision in the tenant. + await expect(e.hasSuspendedRun('run_x')).rejects.toThrow(/connection refused/); + }); + + it('answers false with no store and nothing in memory', async () => { + expect(await pausableEngine().hasSuspendedRun('run_x')).toBe(false); + }); +}); + const terminalRecord = (n: number, overrides: Partial = {}): RunRecord => ({ runId: `r${n}`, flowName: 'busy_flow', diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 9af410cbb9..2875a6945a 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -211,6 +211,17 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore { } } + /** + * Read the backing table once so a misconfiguration surfaces at BOOT rather + * than as a per-suspend write failure nobody reads. Throws the driver error + * verbatim — `no such table: sys_automation_run` means the object was never + * registered (or its schema never synced), which is #4420: a durable store + * that silently persists nothing and zombifies every pause on restart. + */ + async probe(): Promise { + await this.engine.find(TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); + } + async load(runId: string): Promise { const rows = await this.engine.find(TABLE, { where: { id: runId }, limit: 1, context: SYSTEM_CTX, diff --git a/packages/services/service-cache/README.md b/packages/services/service-cache/README.md index 48cb38d6f0..f6d87b5f04 100644 --- a/packages/services/service-cache/README.md +++ b/packages/services/service-cache/README.md @@ -240,14 +240,12 @@ const stats = await cache.stats(); await cache.resetStats(); ``` -## REST API Endpoints +## No HTTP Surface -``` -GET /api/v1/cache/stats # Get cache statistics -POST /api/v1/cache/clear # Clear cache -DELETE /api/v1/cache/:key # Delete specific key -DELETE /api/v1/cache/pattern/:pattern # Delete by pattern -``` +This service is kernel-internal: it is consumed in-process via the service +registry (`kernel.getService('cache')`) and mounts **no** REST routes. +Discovery advertises no route for the `cache` slot and reports +`handlerReady: false` (ADR-0076 D12, #4318). ## Best Practices diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index 49122259b5..af1911ed0b 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -73,7 +73,7 @@ describe('DatasourceAdminServicePlugin: probe', () => { driverFactory: fakeFactory(), }); const res = await service.testConnection( - { name: 'reporting', driver: 'postgres', config: { host: 'db' } }, + { name: 'reporting', driver: 'postgres', config: { host: 'db', database: 'analytics' } }, { value: 's3cret' }, ); expect(res.ok).toBe(true); @@ -91,7 +91,7 @@ describe('DatasourceAdminServicePlugin: probe', () => { it('returns ok:false when no factory is registered at all', async () => { const { service } = await boot(); - const res = await service.testConnection({ name: 'x', driver: 'postgres', config: {} }); + const res = await service.testConnection({ name: 'x', driver: 'postgres', config: { database: 'analytics' } }); expect(res.ok).toBe(false); expect(res.error).toMatch(/no driver factory is registered/i); }); @@ -101,7 +101,10 @@ describe('DatasourceAdminServicePlugin: secret fail-closed', () => { it('refuses to create a secret-bearing datasource without a secret binder', async () => { const { service, registry } = await boot({ driverFactory: fakeFactory() }); await expect( - service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }), + service.createDatasource( + { name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }, + { value: 'pw' }, + ), ).rejects.toThrow(/no secret store configured/i); // nothing persisted expect(registry.get('datasource')?.size ?? 0).toBe(0); @@ -118,7 +121,10 @@ describe('DatasourceAdminServicePlugin: secret fail-closed', () => { }, }, }); - await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }); + await service.createDatasource( + { name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }, + { value: 'pw' }, + ); const rec = registry.get('datasource')?.get('reporting') as any; expect(rec.origin).toBe('runtime'); expect(rec.external?.credentialsRef).toBe('sys_secret://datasource/reporting#1'); @@ -181,7 +187,7 @@ describe('DatasourceAdminServicePlugin: boot rehydration', () => { driver: 'postgres', origin: 'runtime', active: true, - config: { host: 'db' }, + config: { host: 'db', database: 'analytics' }, external: { credentialsRef: 'sys_secret:abc' }, }, ], @@ -220,7 +226,7 @@ describe('DatasourceAdminServicePlugin: persistence + bound count', () => { // seed an object bound to a runtime datasource registry.set('object', new Map([['lead', { name: 'lead', datasource: 'reporting' }]])); - await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }); + await service.createDatasource({ name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }); const list = await service.listDatasources(); expect(list.find((d) => d.name === 'crm_primary')?.origin).toBe('code'); diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index 454f3209eb..28e72bbbbe 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -115,7 +115,7 @@ describe('testConnection', () => { it('probes with the cleartext secret without persisting anything', async () => { const { service, store, probed } = makeHarness(); const res = await service.testConnection( - { name: 'tmp', driver: 'postgres', config: { host: 'db.internal' } }, + { name: 'tmp', driver: 'postgres', config: { host: 'db.internal', database: 'analytics' } }, { value: 's3cret' }, ); expect(res.ok).toBe(true); @@ -136,10 +136,43 @@ describe('testConnection', () => { throw new Error('ECONNREFUSED'); }, }); - const res = await service.testConnection({ name: 'x', driver: 'postgres' }); + const res = await service.testConnection({ + name: 'x', + driver: 'postgres', + config: { database: 'app' }, + }); expect(res.ok).toBe(false); expect(res.error).toMatch(/ECONNREFUSED/); }); + + // #4410. A probe is the wizard's evidence that a connection works, so it must + // not run against a config the driver would silently discard: `hostname` is + // dropped, `pg` opens its own localhost default, and a green "Connection + // successful" is reported for a datasource pointing somewhere else. + it('refuses to probe a config the driver would silently ignore', async () => { + const { service, probed } = makeHarness(); + const res = await service.testConnection({ + name: 'x', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'app' }, + }); + + expect(res.ok).toBe(false); + expect(res.error).toContain('`hostname` → `host`'); + expect(probed).toHaveLength(0); + }); + + it('probes a driver the platform ships no contract for, unchanged', async () => { + const { service, probed } = makeHarness(); + const res = await service.testConnection({ + name: 'x', + driver: 'com.vendor.snowflake', + config: { account: 'xy12345' }, + }); + + expect(res.ok).toBe(true); + expect(probed).toHaveLength(1); + }); }); describe('createDatasource', () => { @@ -168,16 +201,39 @@ describe('createDatasource', () => { it('hot-registers the pool after create', async () => { const { service, registered } = makeHarness(); - await service.createDatasource({ name: 'reporting', driver: 'postgres' }); + await service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { database: 'analytics' }, + }); expect(registered).toContain('reporting'); }); + // The wizard is the OTHER authoring door: `createDatasource` writes through + // `metadata.register`, whose validation is a structural name/label check, so + // a bad config reached the store even after DatasourceSchema's gate landed. + it('rejects a config its driver would not honour (#4410)', async () => { + const { service, store } = makeHarness(); + await expect( + service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'analytics' }, + }), + ).rejects.toThrow(/`hostname` → `host`/); + expect(store.size).toBe(0); + }); + it('rejects a name owned by a code-defined datasource', async () => { const { service } = makeHarness({ seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }], }); await expect( - service.createDatasource({ name: 'crm_primary', driver: 'postgres' }), + service.createDatasource({ + name: 'crm_primary', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/code-defined/i); }); @@ -186,14 +242,22 @@ describe('createDatasource', () => { seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }], }); await expect( - service.createDatasource({ name: 'reporting', driver: 'postgres' }), + service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/already exists/i); }); it('rejects an invalid name', async () => { const { service } = makeHarness(); await expect( - service.createDatasource({ name: 'Bad-Name', driver: 'postgres' }), + service.createDatasource({ + name: 'Bad-Name', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/must match/i); }); }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts index f6a43a4be3..1687021a86 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts @@ -117,15 +117,38 @@ describe('isDatasourceAddressed (ADR-0062 D2 gate)', () => { expect(isDatasourceAddressed({ name: 'x', schemaMode: 'managed', autoConnect: true }, { objects: [] })).toBe(true); }); - it('does NOT connect a managed datasource that is only mapped / unrouted (app-crm byte-for-byte unchanged)', () => { - // app-crm: crm_primary is managed + referenced by datasourceMapping only, - // crm_analytics is managed + unrouted. Neither has an object binding. - expect(isDatasourceAddressed({ name: 'crm_primary', schemaMode: 'managed' }, { objects: [] })).toBe(false); + it('connects when a datasourceMapping rule routes objects to it (d) — #4462', () => { + // The gate D2 originally excluded, to keep a mapped-but-unconnected + // datasource falling through to `default`. That fall-through is exactly how + // an object's rows ended up in a database nobody declared, so routing no + // longer performs it — and once a mapped object has no fallback, connecting + // its datasource at boot is the same call gate (b) already makes. + expect( + isDatasourceAddressed( + { name: 'broken', schemaMode: 'managed' }, + { objects: [], mappedObjects: { broken: ['rc1_audit'] } }, + ), + ).toBe(true); + }); + + it('does NOT connect a managed datasource nothing routes to', () => { + // No object binding, no mapping rule that matches anything. expect(isDatasourceAddressed({ name: 'crm_analytics', schemaMode: 'managed' }, { objects: [] })).toBe(false); // An object bound to a DIFFERENT datasource must not flip the gate. expect( isDatasourceAddressed({ name: 'crm_primary', schemaMode: 'managed' }, { objects: [{ name: 'acct', datasource: 'default' }] }), ).toBe(false); + // Nor may a mapping that routes objects SOMEWHERE ELSE, or one that + // matches no object at all (an empty list is not a route). + expect( + isDatasourceAddressed( + { name: 'crm_primary', schemaMode: 'managed' }, + { objects: [], mappedObjects: { other_ds: ['task'], crm_primary: [] } }, + ), + ).toBe(false); + // A host that supplies no mapping information at all keeps the pre-#4462 + // behavior rather than guessing. + expect(isDatasourceAddressed({ name: 'crm_primary', schemaMode: 'managed' }, {})).toBe(false); }); }); @@ -492,6 +515,38 @@ describe('DatasourceConnectionService.connectDeclared', () => { } }); + // #4462 — the boot half of the pair. Before this, an object mapped to an + // unreachable datasource produced NO connect attempt at all: the D2 gate left + // it metadata-only, so the name never appeared in the log, `/ready` stayed + // 200, and the write went to the default store with a 201. + it('a mapping-routed datasource is attempted at boot, and its failure is fatal', async () => { + const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; + const saved = process.env[ENV]; + delete process.env[ENV]; + try { + const { service } = svc({ factory: fakeFactory({ connectThrows: true }) }); + const err = await service + .connectDeclared({ + datasources: [{ name: 'broken', driver: 'sqlite', schemaMode: 'managed', config: {} }], + objects: [{ name: 'rc1_audit' }], // no explicit binding — routed by the rule + mappedObjects: { broken: ['rc1_audit'] }, + }) + .then( + () => { throw new Error('connectDeclared() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + expect(err.message).toMatch(/^datasource 'broken': connect failed/); + expect(err.message).toContain('datasourceMapping rule'); + expect(err.message).toContain('rc1_audit'); + // The sentence an operator has to be able to act on: their data is NOT + // quietly going somewhere else. + expect(err.message).toContain('DIFFERENT database'); + } finally { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + } + }); + it('a single fatal failure propagates as-is (no aggregate wrapper to read past)', async () => { const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; const saved = process.env[ENV]; diff --git a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts index bb310013f9..c10a0150bd 100644 --- a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts +++ b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts @@ -169,3 +169,97 @@ describe('createDefaultDatasourceDriverFactory — memory construction (#4083)', await explicit.handle.disconnect?.(); }); }); + +// #4410 — the keys that were DECLARED and dropped on the floor. Each of these +// was authorable, strict, documented and read by nothing, so a datasource that +// set it behaved exactly like one that did not. The gate over `datasource.config` +// is only honest if the contract it enforces is one the factory honours, which +// is what these pin. +describe('createDefaultDatasourceDriverFactory — declared keys reach the driver (#4410)', () => { + /** The knex config a constructed SqlDriver was built from. */ + function knexConfigOf(driver: any): any { + return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {}; + } + + it('honours the datasource `pool` block instead of the hardcoded min0/max5', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + pool: { min: 2, max: 20, idleTimeoutMillis: 45_000 }, + }); + const cfg = knexConfigOf(handle.driver ?? handle); + expect(cfg.pool).toMatchObject({ min: 2, max: 20, idleTimeoutMillis: 45_000 }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('keeps the previous defaults when no pool is declared', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + }); + expect(knexConfigOf(handle.driver ?? handle).pool).toMatchObject({ min: 0, max: 5 }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('carries postgres schema / applicationName / statementTimeout onto the connection', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { + host: 'db.internal', + database: 'analytics', + schema: 'reporting', + applicationName: 'objectstack', + statementTimeout: 30_000, + }, + }); + const cfg = knexConfigOf(handle.driver ?? handle); + expect(cfg.searchPath).toBe('reporting'); + expect(cfg.connection).toMatchObject({ + application_name: 'objectstack', + statement_timeout: 30_000, + }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('carries the datasource `ssl` block onto the connection, certificates and all', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + ssl: { enabled: true, rejectUnauthorized: false, ca: 'CA-PEM' }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ + ssl: { rejectUnauthorized: false, ca: 'CA-PEM' }, + }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('reads `ssl: false` on the block as TLS off, not as absent', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', ssl: true }, + ssl: { enabled: false }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ ssl: false }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('falls back to the per-driver boolean shorthand when no block is declared', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', ssl: true }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ ssl: true }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it("applies the datasource's own schemaMode, which never reached the driver before", async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + schemaMode: 'external', + }); + const driver: any = handle.driver ?? handle; + expect(knexConfigOf(driver).schemaMode ?? driver.schemaMode).toBe('external'); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts new file mode 100644 index 0000000000..4452074515 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connection form and the config gate must describe ONE shape (#4410). + * + * Before this, the catalog carried hand-written JSON-Schema literals while + * `packages/spec` carried zod schemas for the same drivers — two descriptions, + * neither checked against the other and neither validating anything, so the + * drift was invisible. Now that the zod side is the gate `DatasourceSchema` and + * `DatasourceAdminService` both parse `config` against, a divergence stops being + * cosmetic: a form field the gate rejects is a Save that cannot succeed, and a + * gate key the form omits is a setting only hand-written JSON can reach. + * + * The catalog is derived rather than reconciled, so these are proofs that the + * derivation is real — the failure they exist to catch is someone "simplifying" + * it back into literals. + */ + +import { describe, it, expect } from 'vitest'; +import { + getDriverConfigJsonSchemaById, + validateDriverConfig, + type BuiltinDriverId, +} from '@objectstack/spec/data'; + +import { DRIVER_CATALOG } from '../driver-catalog.js'; + +describe('DRIVER_CATALOG', () => { + it('serves the spec projection for every offered driver', () => { + expect(DRIVER_CATALOG.length).toBeGreaterThan(0); + for (const entry of DRIVER_CATALOG) { + expect(entry.configSchema, entry.id) + .toBe(getDriverConfigJsonSchemaById(entry.id as BuiltinDriverId)); + } + }); + + it('offers only drivers the platform can build and validate', () => { + for (const entry of DRIVER_CATALOG) { + expect(validateDriverConfig(entry.id, {}).known, entry.id).toBe(true); + } + }); + + it('keeps its curation — label, description and icon per entry', () => { + for (const entry of DRIVER_CATALOG) { + expect(entry.label, entry.id).toBeTruthy(); + expect(entry.description, entry.id).toBeTruthy(); + expect(entry.icon, entry.id).toBeTruthy(); + } + expect(DRIVER_CATALOG.map((d) => d.id)).toEqual(['memory', 'sqlite', 'postgres', 'mysql', 'mongo']); + }); + + /** + * The form renders the AUTHOR-facing shape, so a field carrying a default + * must not be marked required — an input-mode projection, not output-mode. + * Getting this backwards would make the wizard demand a `host` the driver + * already defaults. + */ + it('projects the input shape, so defaulted fields stay optional', () => { + const postgres = DRIVER_CATALOG.find((d) => d.id === 'postgres')!; + const schema = postgres.configSchema as { required?: string[]; properties: Record }; + + expect(Object.keys(schema.properties)).toContain('host'); + expect(schema.required ?? []).not.toContain('host'); + }); + + it('every field the form offers is a field the gate accepts', () => { + for (const entry of DRIVER_CATALOG) { + const schema = entry.configSchema as { properties: Record }; + for (const key of Object.keys(schema.properties)) { + const result = validateDriverConfig(entry.id, { [key]: undefined }); + expect(result.known, `${entry.id}.${key}`).toBe(true); + const issues = (result as { issues: Array<{ message: string }> }).issues; + const unknownKey = issues.some((i) => i.message.includes('Unrecognized key')); + expect(unknownKey, `${entry.id}.${key} is offered by the form but rejected by the gate`) + .toBe(false); + } + } + }); +}); diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts index cc83273d75..500e496218 100644 --- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts @@ -29,12 +29,34 @@ export interface DatasourceConnectionSpec { driver: string; /** Driver-specific connection config (host, port, database, …). No secrets. */ config: Record; + /** + * Schema ownership mode (ADR-0015) — whether ObjectStack owns this schema or + * is a guest in a database it must never run DDL against. + * + * Carried here since #4410. The factory used to look for it on `external` + * (the federation-settings block, which has no such key) and then inside + * `config` (which nothing ever wrote), so a datasource's own declared + * `schemaMode` never reached the driver: an `external` database was + * constructed as `managed`, with DDL ungated at the driver level. + */ + schemaMode?: 'managed' | 'external' | 'validate-only'; /** Cleartext secret (password / DSN) injected for this connection only. */ secret?: string; /** External federation settings (timeouts, allowed schemas, …). */ external?: Record; /** Connection pool settings. */ pool?: Record; + /** + * Datasource-level TLS block (`enabled`, `rejectUnauthorized`, `ca`, `cert`, + * `key`). + * + * Carried here since #4410. It was declared on the datasource, strict, + * documented — and never reached a driver, because it stopped at the record: + * nothing put it on this spec. So a TLS configuration that never took effect + * looked exactly like one that did, which is the failure `datasource.ssl`'s + * own schema comment warns about. + */ + ssl?: Record; } /** diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index bf80f3a348..09a12a64a1 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -21,6 +21,7 @@ * - Removal is refused while objects are still bound to the datasource. */ +import { validateDriverConfig } from '@objectstack/spec/data'; import type { IDatasourceAdminService, DatasourceDraft, @@ -207,6 +208,15 @@ export class DatasourceAdminService implements IDatasourceAdminService { if (!input?.driver) { return { ok: false, error: 'A driver is required to test a connection.' }; } + // Checked BEFORE the probe: a misspelled key makes the driver fall back to + // its own defaults, so the probe would open a connection to localhost and + // report a green "Connection successful" for a datasource that points + // somewhere else entirely — the wizard's version of #4410's core bug. + try { + this.assertValidConfig(input.driver, input.config); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs; try { return await this.config.probe({ @@ -224,6 +234,7 @@ export class DatasourceAdminService implements IDatasourceAdminService { async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise { this.assertValidName(input?.name); if (!input.driver) throw new Error('A driver is required to create a datasource.'); + this.assertValidConfig(input.driver, input.config); const existing = await this.config.getDatasourceRecord(input.name); if (existing) { @@ -278,6 +289,16 @@ export class DatasourceAdminService implements IDatasourceAdminService { merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef }; } + // Judged on the MERGED record, but only when this write actually touches + // the pairing: a new `config`, or a new `driver` that reinterprets the + // stored one. An edit that renames a datasource or flips `active` must not + // be blocked by a config it is not touching — a record written before + // #4410 would otherwise become uneditable, including the `active: false` + // that takes a misconfigured datasource out of service. + if (patch.config !== undefined || patch.driver !== undefined) { + this.assertValidConfig(merged.driver, merged.config); + } + if (secret) { const prevRef = existing.external?.credentialsRef; const credentialsRef = await this.config.writeSecret(secret, { name }); @@ -311,6 +332,28 @@ export class DatasourceAdminService implements IDatasourceAdminService { // --- internals ----------------------------------------------------------- + /** + * Reject a `config` that does not satisfy its driver's contract (#4410). + * + * The wizard is the OTHER authoring surface for a datasource, and it does not + * reach `DatasourceSchema`: `createDatasource` writes through + * `metadata.register`, whose validation is a structural `name`/`label` check, + * not a zod parse. So a `config` typed into the Setup form was accepted here + * even after the spec gate landed — the same silent acceptance, one door + * along. Both doors now consult the same registry. + * + * A driver the platform ships no contract for passes untouched, matching the + * spec gate's boundary rather than inventing a stricter one for the UI. + */ + private assertValidConfig(driver: string, config: unknown): void { + const result = validateDriverConfig(driver, config); + if (!result.known || result.issues.length === 0) return; + const detail = result.issues + .map((issue) => (issue.path.length ? `config.${issue.path.join('.')}: ${issue.message}` : issue.message)) + .join('\n'); + throw new Error(`Invalid configuration for driver '${driver}'.\n${detail}`); + } + private assertValidName(name: string | undefined): void { if (!name || !NAME_RE.test(name)) { throw new Error( diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index b9ae5bddd5..77a0dda750 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -53,6 +53,8 @@ export interface ConnectableDatasource { validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' }; }) | undefined; pool?: Record; + /** Datasource-level TLS block — carried to the driver since #4410. */ + ssl?: Record; active?: boolean; origin?: 'code' | 'runtime'; /** @@ -208,32 +210,50 @@ export function availabilityOf(status: ConnectStatus): DatasourceAvailability { * Returns true when: * - (a) it is external (`schemaMode !== 'managed'`), OR * - (b) some object **explicitly** binds to it (`object.datasource === name`), OR - * - (c) it sets `autoConnect: true`. + * - (c) it sets `autoConnect: true`, OR + * - (d) a `datasourceMapping` rule ROUTES at least one registered object to it. * - * Deliberately NOT triggered by a `datasourceMapping` rule alone. A managed - * datasource that is only *mapped* (namespace/package/default) but has no live - * driver historically falls through to the `default` driver at query time - * (`engine.getDriver` step 4) — e.g. `examples/app-crm`'s `crm_primary` - * (`:memory:`, mapped + default-fallback, no `onEnable`). Connecting it would - * divert those objects to a fresh, empty connection and silently change app - * behavior. So mapping-only routing to a *managed* datasource is treated as - * decorative, keeping existing apps byte-for-byte unchanged (D2's load-bearing - * backward-compat guarantee). External datasources and explicit - * `object.datasource` bindings never resolved to `default` (they throw when - * unregistered), so auto-connecting them is a strict improvement, not a change. + * ## (d), and why D2's phase-1 note no longer holds (#4462) * - * That same "no fallback" property is why gate (b) is also a **fail-fast** - * trigger when the connect fails (framework#3758) — see - * {@link DatasourceConnectionService.handleFailure}. Gate (c) is not: nothing - * declares a dependency on an `autoConnect` datasource. + * D2 originally excluded (d) to keep `examples/app-crm` byte-for-byte + * unchanged: its `crm_primary` was mapped but had no driver, so + * `engine.getDriver` fell through to `default` and the app worked. Connecting + * it would have diverted those objects to a fresh, empty connection — a + * behavior change. So a mapping-only managed datasource was declared + * "decorative". + * + * What that traded away was not visible from inside the boot path. An operator + * who maps an object to an unreachable Postgres gets: a clean boot, `/ready` + * 200, the datasource name in zero log lines, a `201` on the write, and their + * rows in the DEFAULT store. They find out by going to look in the database + * they declared and finding it empty. "Decorative" is not what a mapping rule + * reads as; it reads as routing. + * + * The fix is the pair, and each half is what makes the other correct: routing + * no longer falls through when a mapped datasource has no driver, so a mapped + * object now has NO FALLBACK — which is exactly the property that made (b) + * safe to auto-connect and fatal to fail. (d) inherits both. + * + * `ctx.mappedObjects` is supplied by the boot path from the ENGINE's own + * resolver, never re-derived here — see `ObjectQLEngine.resolveMappedDatasource`. + * A host that cannot supply it (no engine yet, no mapping configured) passes + * nothing and (d) simply never fires, which is the pre-#4462 behavior. + * + * Gate (c) is not a fail-fast trigger: nothing declares a dependency on an + * `autoConnect` datasource. */ export function isDatasourceAddressed( ds: Pick, - ctx: { objects?: readonly DatasourceBoundObject[] }, + ctx: { + objects?: readonly DatasourceBoundObject[]; + /** Datasource name → the objects a `datasourceMapping` rule routes to it. */ + mappedObjects?: Readonly>; + }, ): boolean { if (ds.schemaMode && ds.schemaMode !== 'managed') return true; // (a) if (ds.autoConnect === true) return true; // (c) if (ctx.objects?.some((o) => o?.datasource === ds.name)) return true; // (b) + if ((ctx.mappedObjects?.[ds.name]?.length ?? 0) > 0) return true; // (d) return false; } @@ -285,20 +305,33 @@ export class DatasourceConnectionService { async connectDeclared(input: { datasources: readonly ConnectableDatasource[]; objects?: readonly DatasourceBoundObject[]; + /** + * Datasource name → the objects a `datasourceMapping` rule routes to it + * (#4462), resolved by the caller from the ENGINE's own rule matcher so + * this service never re-implements "does this rule match?". Absent ⇒ gate + * (d) never fires, which is the pre-#4462 behavior. + */ + mappedObjects?: Readonly>; }): Promise { const objects = input.objects ?? []; + const mappedObjects = input.mappedObjects ?? {}; const results: ConnectResult[] = []; const fatal: Error[] = []; for (const ds of input.datasources) { if (!ds?.name) continue; if (ds.active === false) continue; - if (!isDatasourceAddressed(ds, { objects })) continue; // D2 gate + if (!isDatasourceAddressed(ds, { objects, mappedObjects })) continue; // D2 gate const bound = objects .filter((o) => o?.datasource === ds.name && typeof o?.name === 'string') .map((o) => o.name as string); + const mapped = mappedObjects[ds.name] ?? []; try { results.push( - await this.connect(ds, { objects: bound, context: { origin: ds.origin ?? 'code', trigger: 'declared-auto' } }), + await this.connect(ds, { + objects: bound, + mappedObjects: mapped, + context: { origin: ds.origin ?? 'code', trigger: 'declared-auto' }, + }), ); } catch (err) { fatal.push(err instanceof Error ? err : new Error(String(err))); @@ -335,6 +368,12 @@ export class DatasourceConnectionService { record: ConnectableDatasource, opts: { objects?: readonly string[]; + /** + * Objects a `datasourceMapping` rule routes here (#4462). Like + * `objects`, these have no fallback since routing stopped falling + * through — so a boot-time failure with any of them is fatal. + */ + mappedObjects?: readonly string[]; context?: DatasourceConnectContext; /** * Register the built driver as the engine's DEFAULT driver, under the @@ -393,7 +432,7 @@ export class DatasourceConnectionService { private async attemptConnect( record: ConnectableDatasource, - opts: { objects?: readonly string[]; context?: DatasourceConnectContext; asDefault?: boolean } = {}, + opts: { objects?: readonly string[]; mappedObjects?: readonly string[]; context?: DatasourceConnectContext; asDefault?: boolean } = {}, ): Promise { const name = record.name; const engine = this.cfg.engine(); @@ -440,6 +479,7 @@ export class DatasourceConnectionService { `no driver factory supports driver '${record.driver}'`, opts.context, opts.objects, + opts.mappedObjects, ); } @@ -467,7 +507,7 @@ export class DatasourceConnectionService { try { secret = await resolver(credentialsRef); } catch (err) { - return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context, opts.objects); + return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context, opts.objects, opts.mappedObjects); } if (secret == null || secret === '') { return this.handleFailure( @@ -519,7 +559,7 @@ export class DatasourceConnectionService { this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? 'managed'})`); return { name, status: 'connected', ...(handle.ownership ? { ownership: handle.ownership } : {}) }; } catch (err) { - return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects); + return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects, opts.mappedObjects); } } @@ -591,7 +631,12 @@ export class DatasourceConnectionService { * - **(c)** the host marked it {@link ConnectableDatasource.bootCritical} — * the standalone `default` (#3826): everything WITHOUT a binding routes to * it, so "no fallback" holds by construction, mirroring the engine-level - * guard (#3741) this connect path replaces. + * guard (#3741) this connect path replaces; or + * - **(d)** a `datasourceMapping` rule routes objects to it (#4462). Same + * argument as (b), reached one clause later: since routing stopped falling + * through on a mapped-but-unconnected datasource, those objects have no + * fallback either. Before that, this case was not merely non-fatal — it was + * SILENT, and the objects' rows went to the default store. * * Anything else degrades with a warning: `autoConnect:true` means "connect it * if you can" with nothing declaring a dependency on it, and runtime-admin @@ -614,6 +659,7 @@ export class DatasourceConnectionService { reason: string, context?: DatasourceConnectContext, boundObjects: readonly string[] = [], + mappedObjects: readonly string[] = [], ): ConnectResult { const isExternal = record.schemaMode && record.schemaMode !== 'managed'; const msg = `datasource '${record.name}': connect failed — ${reason}`; @@ -629,6 +675,13 @@ export class DatasourceConnectionService { `and have no fallback datasource — every read/write of them would fail`, ); } + if (mappedObjects.length > 0) { + causes.push( + `${mappedObjects.length} object(s) are routed to it by a datasourceMapping rule ` + + `(${formatObjectList(mappedObjects)}) and have no fallback datasource — their reads/writes ` + + `would otherwise land in a DIFFERENT database than the one they declare`, + ); + } if (record.bootCritical === true) { causes.push( `declared boot-critical by the host — it is the platform's primary datasource and ` + @@ -673,8 +726,12 @@ function toSpec(record: ConnectableDatasource): DatasourceConnectionSpec { name: record.name, driver: record.driver, config: record.config ?? {}, + // #4410: dropped here before, which is why the factory went looking for + // `schemaMode` in two places that could never hold it. + ...(record.schemaMode ? { schemaMode: record.schemaMode } : {}), external: record.external, pool: record.pool, + ssl: record.ssl, }; } diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 72c5ec14b5..a2e1c0671b 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -15,10 +15,13 @@ * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3) * - `sqlite-wasm` / `wasm-sqlite` → `@objectstack/driver-sqlite-wasm` (pure-JS) * - `mysql` / `mysql2` → `@objectstack/driver-sql` (client `mysql2`) - * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep) - * - `memory` / `inmemory` → `@objectstack/driver-memory` (ephemeral, + * - `mongo` / `mongodb` → `@objectstack/driver-mongodb` (peer dep) + * - `memory` / `inmemory` → `@objectstack/driver-memory` (ephemeral, * per-datasource — see {@link buildMemoryConfig}) * + * The full alias table lives in `@objectstack/spec` (`resolveDriverId`), which + * is also what selects each driver's config contract — see {@link resolveKind}. + * * `sqlite-wasm` joined for ADR-0062 D1 (#3826): the standalone stack's * `default` datasource is a *declared definition* connected through the shared * `DatasourceConnectionService`, and its CI-safe wasm default must therefore be @@ -32,34 +35,28 @@ */ import { join } from 'node:path'; +import { resolveDriverId, type BuiltinDriverId } from '@objectstack/spec/data'; import type { IDatasourceDriverFactory, DatasourceConnectionSpec, DatasourceDriverHandle, } from './contracts/index.js'; -type ResolvedKind = 'postgres' | 'sqlite' | 'sqlite-wasm' | 'mysql' | 'mongodb' | 'memory'; - -const DRIVER_ID_ALIASES: Record = { - postgres: 'postgres', - postgresql: 'postgres', - pg: 'postgres', - sqlite: 'sqlite', - sqlite3: 'sqlite', - 'better-sqlite3': 'sqlite', - 'sqlite-wasm': 'sqlite-wasm', - 'wasm-sqlite': 'sqlite-wasm', - mysql: 'mysql', - mysql2: 'mysql', - mongodb: 'mongodb', - mongo: 'mongodb', - memory: 'memory', - inmemory: 'memory', - 'in-memory': 'memory', -}; +/** + * Driver-id resolution comes from the spec since #4410 — this file used to keep + * its own copy of the alias table. + * + * Two tables meant the id that selects a DRIVER and the id that selects that + * driver's CONFIG CONTRACT could disagree: a spelling only this table knew + * would be built while its config was validated against nothing — the exact + * silent acceptance the config gate exists to end, reintroduced as a lookup + * miss. One table, so "buildable" and "has a contract" are the same set by + * construction. + */ +type ResolvedKind = BuiltinDriverId; function resolveKind(driverId: string): ResolvedKind | undefined { - return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()]; + return resolveDriverId(driverId); } /** @@ -78,11 +75,64 @@ function toHandle(driver: any, serverVersion?: () => Promise }; } +/** + * Postgres connection options that are neither the target nor the credentials — + * declared on `PostgresConfigSchema` and carried onto every connection shape + * (DSN or discrete fields alike), since `pg` accepts them next to a + * `connectionString`. + * + * These were declared in the spec and read by nothing until #4410. Giving + * `config` a gate means every key inside it now claims to be honoured, so each + * one is either wired (here) or removed from the contract — a declared key that + * silently does nothing is the defect this whole campaign is about. + */ +function pgConnectionExtras(cfg: Record): Record { + return { + ...(cfg.applicationName ? { application_name: cfg.applicationName } : {}), + ...(cfg.statementTimeout != null ? { statement_timeout: cfg.statementTimeout } : {}), + }; +} + +/** + * The `ssl` value to hand a SQL client, from the datasource's TLS block or the + * per-driver on/off shorthand. + * + * `datasource.ssl` is declared, strict, documented — and until #4410 stopped at + * the record: nothing put it on the connection spec, so a TLS block with a CA + * certificate in it configured precisely nothing, which is the failure its own + * schema comment warns about ("a TLS setting that never took effect looked + * identical to one that did"). The block wins when present because it is the + * more specific statement; `config.ssl` remains the boolean shorthand. + */ +function resolveSslOption(spec: DatasourceConnectionSpec): unknown { + const block = spec.ssl as + | { enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; key?: string } + | undefined; + if (block) { + if (block.enabled === false) return false; + const options = { + ...(block.rejectUnauthorized !== undefined ? { rejectUnauthorized: block.rejectUnauthorized } : {}), + ...(block.ca ? { ca: block.ca } : {}), + ...(block.cert ? { cert: block.cert } : {}), + ...(block.key ? { key: block.key } : {}), + }; + // `ssl: {}` would read as "TLS with default options" to `pg`, which is what + // `enabled: true` with nothing else means anyway — but an empty object is + // an odd thing to hand a client, so collapse it to the boolean. + return Object.keys(options).length > 0 ? options : true; + } + const shorthand = (spec.config ?? {}).ssl; + return shorthand == null ? undefined : shorthand; +} + /** Build the Knex `connection` for a SQL driver from a spec's config + secret. */ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown { const cfg = (spec.config ?? {}) as Record; if (client === 'better-sqlite3') { + // `file` / `database` are pre-#4410 tolerance for shapes already persisted + // by the runtime store. Authoring rejects both with a rename hint + // (`SqliteConfigSchema`), so nothing new can arrive spelled this way. const filename = (cfg.filename as string | undefined) ?? (cfg.file as string | undefined) ?? @@ -93,10 +143,18 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett // pg — accept either a connection string (`url`/`connectionString`) or // discrete fields. The secret is the password and is never part of `config`. + const ssl = resolveSslOption(spec); const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined); if (url) { // For a DSN, a separately-supplied secret overrides the embedded password. - return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url }; + // TLS still applies: `sslmode` in a DSN and the `ssl` option are separate + // channels to `pg`, and a datasource that declares one should get it. + return { + connectionString: url, + ...(spec.secret ? { password: spec.secret } : {}), + ...(ssl !== undefined ? { ssl } : {}), + ...pgConnectionExtras(cfg), + }; } return { host: cfg.host, @@ -104,7 +162,29 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett database: cfg.database, user: cfg.user ?? cfg.username, ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}), - ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}), + ...(ssl !== undefined ? { ssl } : {}), + ...pgConnectionExtras(cfg), + }; +} + +/** + * Knex pool options for a SQL driver, from the datasource's own `pool` block. + * + * `datasource.pool` is declared, strict, documented and — until #4410 — read by + * nobody: `toSpec` carried it into the connection spec and this factory then + * hardcoded `{ min: 0, max: 5 }` over the top, so an author who sized their pool + * got the defaults and no indication. Those defaults are preserved for the + * unspecified case, so nothing that did not set `pool` changes behaviour. + */ +function buildSqlPool(spec: DatasourceConnectionSpec): Record { + const pool = (spec.pool ?? {}) as Record; + return { + min: typeof pool.min === 'number' ? pool.min : 0, + max: typeof pool.max === 'number' ? pool.max : 5, + ...(typeof pool.idleTimeoutMillis === 'number' ? { idleTimeoutMillis: pool.idleTimeoutMillis } : {}), + ...(typeof pool.connectionTimeoutMillis === 'number' + ? { acquireTimeoutMillis: pool.connectionTimeoutMillis } + : {}), }; } @@ -116,6 +196,7 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett */ function buildMysqlConnection(spec: DatasourceConnectionSpec): unknown { const cfg = (spec.config ?? {}) as Record; + const mysqlSsl = resolveSslOption(spec); const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined); if (url) return url; return { @@ -124,7 +205,7 @@ function buildMysqlConnection(spec: DatasourceConnectionSpec): unknown { database: cfg.database, user: cfg.user ?? cfg.username, ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}), - ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}), + ...(mysqlSsl !== undefined ? { ssl: mysqlSsl } : {}), }; } @@ -200,17 +281,31 @@ function buildMemoryConfig(spec: DatasourceConnectionSpec): Record; + // `uri` is pre-#4410 tolerance for already-persisted shapes; authoring + // rejects it with a rename hint to `url` (`MongoConfigSchema`). const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined); if (explicit) return explicit; const host = (cfg.host as string | undefined) ?? 'localhost'; const port = (cfg.port as number | string | undefined) ?? 27017; const db = (cfg.database as string | undefined) ?? ''; const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined); - const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : ''; - return `mongodb://${auth}${host}:${port}/${db}`; + const password = spec.secret ?? (cfg.password as string | undefined) ?? ''; + const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(password)}@` : ''; + const authSource = cfg.authSource as string | undefined; + const query = authSource ? `?authSource=${encodeURIComponent(authSource)}` : ''; + return `mongodb://${auth}${host}:${port}/${db}${query}`; } /** @@ -242,7 +337,15 @@ export function createDefaultDatasourceDriverFactory( throw new Error(`Unsupported driver id '${spec.driver}'.`); } - const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode + // ADR-0015's ownership mode. `spec.schemaMode` — the datasource's own + // declared key — is FIRST since #4410; before that the first two arms + // were all there was, and neither could ever hold it: `external` is the + // federation-settings block (no `schemaMode` key), and nothing wrote the + // `config` copy. So `schemaMode: 'external'` on a datasource reached the + // driver as `undefined` and a database ObjectStack is a guest in was + // treated as managed — DDL ungated at the driver. + const schemaMode = spec.schemaMode + ?? (spec.external as { schemaMode?: string } | undefined)?.schemaMode ?? ((spec.config as Record | undefined)?.schemaMode as string | undefined); // Host-composition passthroughs (#3826): the CLI's declared `default` // definition carries the dev loosen-only self-heal (#2186) and the wasm @@ -254,10 +357,15 @@ export function createDefaultDatasourceDriverFactory( if (kind === 'postgres') { const { SqlDriver } = await import('@objectstack/driver-sql'); + // `searchPath` is knex's own key for postgres' default schema — the + // landing site for `config.schema`, declared since the protocol's first + // postgres shape and read by nothing until #4410. + const searchPath = cfg.schema as string | undefined; const driver = new SqlDriver({ client: 'pg', connection: buildSqlConnection(spec, 'pg') as any, - pool: { min: 0, max: 5 }, + pool: buildSqlPool(spec), + ...(searchPath ? { searchPath } : {}), ...(schemaMode ? { schemaMode: schemaMode as any } : {}), ...(autoMigrate ? { autoMigrate } : {}), } as any); @@ -310,14 +418,14 @@ export function createDefaultDatasourceDriverFactory( const driver = new SqlDriver({ client: 'mysql2', connection: buildMysqlConnection(spec) as any, - pool: { min: 0, max: 5 }, + pool: buildSqlPool(spec), ...(schemaMode ? { schemaMode: schemaMode as any } : {}), ...(autoMigrate ? { autoMigrate } : {}), } as any); return toHandle(driver); } - if (kind === 'mongodb') { + if (kind === 'mongo') { let MongoDBDriver: any; try { ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any)); @@ -326,7 +434,17 @@ export function createDefaultDatasourceDriverFactory( `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`, ); } - const driver = new MongoDBDriver({ url: buildMongoUrl(spec) }); + // `options` (the MongoClient passthrough) and the datasource's `pool` + // block reach the client since #4410 — the driver has always read + // `options` / `minPoolSize` / `maxPoolSize`; only `url` was ever passed. + const pool = (spec.pool ?? {}) as Record; + const driver = new MongoDBDriver({ + url: buildMongoUrl(spec), + ...(cfg.database ? { database: cfg.database } : {}), + ...(cfg.options && typeof cfg.options === 'object' ? { options: cfg.options } : {}), + ...(typeof pool.min === 'number' ? { minPoolSize: pool.min } : {}), + ...(typeof pool.max === 'number' ? { maxPoolSize: pool.max } : {}), + }); return toHandle(driver); } diff --git a/packages/services/service-datasource/src/driver-catalog.ts b/packages/services/service-datasource/src/driver-catalog.ts index 0d275717fc..ba0ba7f359 100644 --- a/packages/services/service-datasource/src/driver-catalog.ts +++ b/packages/services/service-datasource/src/driver-catalog.ts @@ -11,8 +11,26 @@ * Served by `GET /api/v1/datasources/drivers`. This is the curated set of * connection drivers the connection form offers; a future runtime driver * registry can supersede this list without changing the route contract. + * + * ## The schemas are PROJECTED, not written here (#4410) + * + * They used to be JSON-Schema literals maintained in this file, in parallel + * with `packages/spec`'s per-driver zod schemas — two descriptions of one shape, + * neither checked against the other, and neither validating anything. #4410 + * made the zod side the gate `DatasourceSchema` parses `config` against, which + * turns that duplication from untidy into dangerous: a form offering a field the + * gate rejects is a Setup wizard whose Save cannot succeed, with the platform's + * own form as the thing at fault. + * + * So the form renders the projection of the same schema that judges the save. + * What stays local is CURATION — which drivers the form offers, and their + * label/description/icon. `sqlite-wasm` is deliberately absent: it is + * constructible and has a config contract, but it exists for CI and + * no-native-build environments rather than as something an admin picks here. */ +import { getDriverConfigJsonSchemaById, type BuiltinDriverId } from '@objectstack/spec/data'; + export interface DriverCatalogEntry { /** Unique driver identifier used as `datasource.driver`. */ id: string; @@ -26,88 +44,46 @@ export interface DriverCatalogEntry { configSchema: Record; } -const SSL_PROP = { - ssl: { type: 'boolean', title: 'Use SSL/TLS', default: false }, -} as const; - -export const DRIVER_CATALOG: DriverCatalogEntry[] = [ +/** The curated part — everything except the shape, which comes from the spec. */ +const CURATED: ReadonlyArray<{ + id: BuiltinDriverId; + label: string; + description: string; + icon: string; +}> = [ { id: 'memory', label: 'In-Memory', description: 'Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.', icon: 'memory-stick', - configSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { id: 'sqlite', label: 'SQLite', description: 'File-backed (or in-memory) SQL database. Great for local dev and small deployments.', icon: 'database', - configSchema: { - type: 'object', - properties: { - filename: { - type: 'string', - title: 'Filename', - description: 'Database file path, or ":memory:" for an ephemeral in-memory database.', - default: ':memory:', - }, - }, - required: ['filename'], - additionalProperties: false, - }, }, { id: 'postgres', label: 'PostgreSQL', description: 'PostgreSQL connection. Supply host/port/database or a connection URL.', icon: 'database', - configSchema: { - type: 'object', - properties: { - url: { type: 'string', title: 'Connection URL', description: 'postgres://user:pass@host:5432/db (overrides the fields below when set).' }, - host: { type: 'string', title: 'Host', default: 'localhost' }, - port: { type: 'number', title: 'Port', default: 5432 }, - database: { type: 'string', title: 'Database' }, - username: { type: 'string', title: 'User' }, - password: { type: 'string', title: 'Password', format: 'password' }, - schema: { type: 'string', title: 'Schema', default: 'public' }, - ...SSL_PROP, - }, - additionalProperties: true, - }, }, { id: 'mysql', label: 'MySQL / MariaDB', description: 'MySQL or MariaDB connection.', icon: 'database', - configSchema: { - type: 'object', - properties: { - host: { type: 'string', title: 'Host', default: 'localhost' }, - port: { type: 'number', title: 'Port', default: 3306 }, - database: { type: 'string', title: 'Database' }, - username: { type: 'string', title: 'User' }, - password: { type: 'string', title: 'Password', format: 'password' }, - ...SSL_PROP, - }, - additionalProperties: true, - }, }, { id: 'mongo', label: 'MongoDB', description: 'MongoDB connection via a connection URI.', icon: 'database', - configSchema: { - type: 'object', - properties: { - url: { type: 'string', title: 'Connection URI', description: 'mongodb://host:27017' }, - database: { type: 'string', title: 'Database' }, - }, - required: ['url'], - additionalProperties: true, - }, }, ]; + +export const DRIVER_CATALOG: DriverCatalogEntry[] = CURATED.map((entry) => ({ + ...entry, + configSchema: getDriverConfigJsonSchemaById(entry.id), +})); diff --git a/packages/services/service-job/README.md b/packages/services/service-job/README.md index 6fbbd796d5..a86b2cb633 100644 --- a/packages/services/service-job/README.md +++ b/packages/services/service-job/README.md @@ -312,17 +312,12 @@ jobs.scheduleInterval({ }); ``` -## REST API Endpoints +## No HTTP Surface -``` -GET /api/v1/jobs # List all jobs -GET /api/v1/jobs/:name # Get job details -POST /api/v1/jobs/:name/run # Run job immediately -POST /api/v1/jobs/:name/stop # Stop job -POST /api/v1/jobs/:name/resume # Resume job -DELETE /api/v1/jobs/:name # Delete job -GET /api/v1/jobs/:name/history # Get execution history -``` +This service is kernel-internal: it is consumed in-process via the service +registry (`kernel.getService('job')`) and mounts **no** REST routes. Discovery +advertises no route for the `job` slot and reports `handlerReady: false` +(ADR-0076 D12, #4318). ## Best Practices diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 6ebe74467c..edffaf5066 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -70,7 +70,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`flow.zod.ts`](src/automation/flow.zod.ts) | ⭐ | **Visual Flow**. Complex orchestration logic (decisions, loops, CRUD). | | [`approval.zod.ts`](src/automation/approval.zod.ts) | ⭐ | **Approval Node**. Flow node config for human approval pauses. | | [`webhook.zod.ts`](src/automation/webhook.zod.ts) | ⭐ | **Webhooks**. Outbound HTTP notification configuration. | -| [`trigger-registry.zod.ts`](src/automation/trigger-registry.zod.ts) | | **Trigger Registry**. Central registry for all automation triggers. | | [`etl.zod.ts`](src/automation/etl.zod.ts) | | **ETL Jobs**. Extract-Transform-Load definitions. | | [`sync.zod.ts`](src/automation/sync.zod.ts) | | **Data Sync**. Bi-directional synchronization rules. | @@ -117,13 +116,7 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | File | Status | Description | | :--- | :--- | :--- | -| [`connector.zod.ts`](src/integration/connector.zod.ts) | ⭐ | **Connector Definition**. Metadata for external API integrations (OpenAPI wrapper). | -| [`connector/saas.zod.ts`](src/integration/connector/saas.zod.ts) | | **SaaS Connectors**. Specifics for SaaS APIs (Salesforce, Stripe). | -| [`connector/database.zod.ts`](src/integration/connector/database.zod.ts) | | **DB Connectors**. External database integration. | -| [`connector/file-storage.zod.ts`](src/integration/connector/file-storage.zod.ts) | | **Storage Connectors**. S3, Blob Storage integrations. | -| [`connector/message-queue.zod.ts`](src/integration/connector/message-queue.zod.ts) | | **MQ Connectors**. Kafka, RabbitMQ integrations. | -| [`connector/github.zod.ts`](src/integration/connector/github.zod.ts) | | **GitHub Connector**. Logic for Git integration. | -| [`connector/vercel.zod.ts`](src/integration/connector/vercel.zod.ts) | | **Vercel Connector**. Deployment integration. | +| [`connector.zod.ts`](src/integration/connector.zod.ts) | ⭐ | **Connector Protocol** (ADR-0097). One schema; provider shapes come from the provider itself (connector-openapi / connector-mcp), not from per-provider spec files — the six `connector/*.zod.ts` "templates" were removed in #4480 (zero consumers; ADR-0023 rejected that design). | --- diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 47971d0d1a..68239f63d3 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -201,11 +201,15 @@ "ApiPrimitive (type)", "AuditProvenanceField (type)", "AuthoringKeySurface (type)", + "AutoPersistenceConfig (type)", + "AutoPersistenceConfigSchema (const)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", + "BUILTIN_DRIVER_IDS (const)", "BaseEngineOptions (type)", "BaseEngineOptionsSchema (const)", "BaseValidationRuleShape (interface)", + "BuiltinDriverId (type)", "CALENDAR_DATE_TYPES (const)", "CLOCK_TIME_TYPES (const)", "COMPUTED_VALUE_TYPES (const)", @@ -237,6 +241,8 @@ "CurrencyConfigSchema (const)", "CurrencyValue (type)", "CurrencyValueSchema (const)", + "CustomPersistenceConfig (type)", + "CustomPersistenceConfigSchema (const)", "DATA_ACTION_TO_API_OPERATION (const)", "DATE_MACRO_ALIAS_TOKENS (const)", "DATE_MACRO_DESCRIPTIONS (const)", @@ -246,6 +252,8 @@ "DATE_MACRO_TOKENS (const)", "DATE_MACRO_UNITS (const)", "DATE_MACRO_WRAPPED_RE (const)", + "DRIVER_CONFIG_SCHEMAS (const)", + "DRIVER_ID_ALIASES (const)", "DataEngineAggregateOptions (type)", "DataEngineAggregateOptionsSchema (const)", "DataEngineAggregateRequestSchema (const)", @@ -305,12 +313,15 @@ "DriverCapabilities (type)", "DriverCapabilitiesSchema (const)", "DriverConfig (type)", + "DriverConfigIssue (interface)", "DriverConfigSchema (const)", + "DriverDefinition (type)", "DriverDefinitionSchema (const)", "DriverInterface (type)", "DriverInterfaceSchema (const)", "DriverOptions (type)", "DriverOptionsSchema (const)", + "DriverSslToggleSchema (const)", "DriverType (const)", "DroppedFieldsEvent (type)", "DroppedFieldsEventSchema (const)", @@ -369,6 +380,8 @@ "FieldSchema (const)", "FieldType (type)", "FileLikeValueSchema (const)", + "FilePersistenceConfig (type)", + "FilePersistenceConfigSchema (const)", "FileReferenceIdValueSchema (const)", "FileValueSchema (const)", "Filter (type)", @@ -409,6 +422,8 @@ "LifecycleClass (type)", "LifecycleClassSchema (const)", "LifecycleSchema (const)", + "LocalStoragePersistenceConfig (type)", + "LocalStoragePersistenceConfigSchema (const)", "LocationCoordinates (type)", "LocationCoordinatesSchema (const)", "LocationValueSchema (const)", @@ -419,8 +434,18 @@ "Mapping (type)", "MappingInput (type)", "MappingSchema (const)", + "MemoryConfig (type)", + "MemoryConfigSchema (const)", + "MemoryDriverSpec (const)", + "MemoryPersistenceConfig (type)", + "MemoryPersistenceConfigSchema (const)", "Metric (type)", "MetricSchema (const)", + "MongoConfig (type)", + "MongoConfigSchema (const)", + "MongoDriverSpec (const)", + "MysqlConfig (type)", + "MysqlConfigSchema (const)", "NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", @@ -475,8 +500,14 @@ "PerOperationRequiredPermissionsSchema (const)", "PercentScale (type)", "PercentScaleFieldMeta (interface)", + "PersistenceAdapter (type)", + "PersistenceAdapterSchema (const)", + "PersistenceType (type)", + "PersistenceTypeSchema (const)", "PoolConfig (type)", "PoolConfigSchema (const)", + "PostgresConfig (type)", + "PostgresConfigSchema (const)", "ProvisionPrimaryOptions (interface)", "QUERY_CURSOR_REMOVED (const)", "QUERY_DISTINCT_REMOVED (const)", @@ -488,6 +519,7 @@ "QueryInput (type)", "QuerySchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY (const)", + "READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES (const)", "RPC_QUERY_ALIAS_SLOTS (const)", @@ -514,6 +546,7 @@ "RowCrudActionOverrideInput (type)", "RowCrudActionOverrideSchema (const)", "RowCrudPredicates (interface)", + "SCHEMA_MODE_BELONGS_ON_DATASOURCE (const)", "SEARCHABLE_ENUM_TYPES (const)", "SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS (const)", @@ -527,6 +560,7 @@ "SQLiteDataTypeMappingDefaults (const)", "SSLConfig (type)", "SSLConfigSchema (const)", + "SSL_DETAIL_BELONGS_ON_DATASOURCE (const)", "STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS (const)", "STRING_VALUE_TYPES (const)", @@ -567,7 +601,15 @@ "SortNode (type)", "SortNodeSchema (const)", "SpecialOperatorSchema (const)", + "SqlAutoMigrate (type)", + "SqlAutoMigrateSchema (const)", "SqlDialect (type)", + "SqliteConfig (type)", + "SqliteConfigSchema (const)", + "SqliteWasmConfig (type)", + "SqliteWasmConfigSchema (const)", + "SqliteWasmPersistMode (type)", + "SqliteWasmPersistModeSchema (const)", "StateMachineValidation (type)", "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", @@ -613,11 +655,20 @@ "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", + "driverConfigJsonSchema (function)", "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", "fieldForm (const)", "foldQueryAliasSlots (function)", "formatUnknownAuthoringKey (function)", + "getDriverConfigJsonSchemaById (function)", + "getDriverConfigSchema (function)", + "getMemoryConfigJsonSchema (const)", + "getMongoConfigJsonSchema (const)", + "getMysqlConfigJsonSchema (const)", + "getPostgresConfigJsonSchema (const)", + "getSqliteConfigJsonSchema (const)", + "getSqliteWasmConfigJsonSchema (const)", "hasDynamicTokens (function)", "hookForm (const)", "isApiOperationAllowed (function)", @@ -646,10 +697,12 @@ "parseFilterAST (function)", "percentScaleOf (function)", "provisionPrimary (function)", + "referenceTargetOf (function)", "referencedFields (function)", "renderAutonumber (function)", "resolveCrudAffordances (function)", "resolveDisplayField (function)", + "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", "resolveRecordDisplayName (function)", "resolveSearchFieldResolution (function)", @@ -658,6 +711,7 @@ "stripLegacyApiMethods (function)", "suggestFieldType (function)", "utcInstantMs (function)", + "validateDriverConfig (function)", "valueSchemaFor (function)" ], "./system": [ @@ -1356,6 +1410,7 @@ "emailTemplateForm (const)", "gcsStorageExample (const)", "hasPlatformObjectPrefix (function)", + "inProcessServiceMessage (function)", "interpolateValidationMessage (function)", "isDataMigrationFlagVerified (function)", "isPlatformProvidedObjectName (function)", @@ -1567,28 +1622,14 @@ "MetadataChangeTypeSchema (const)", "MetadataChangedEventPayload (type)", "MetadataChangedEventPayloadSchema (const)", - "MetadataCollectionInfo (type)", - "MetadataCollectionInfoSchema (const)", "MetadataDependency (type)", "MetadataDependencySchema (const)", "MetadataDiffItem (type)", "MetadataDiffItemSchema (const)", "MetadataEvent (type)", "MetadataEventSchema (const)", - "MetadataExportOptions (type)", - "MetadataExportOptionsSchema (const)", "MetadataFallbackStrategy (type)", "MetadataFallbackStrategySchema (const)", - "MetadataFormat (type)", - "MetadataFormatSchema (const)", - "MetadataImportOptions (type)", - "MetadataImportOptionsSchema (const)", - "MetadataLoadOptions (type)", - "MetadataLoadOptionsSchema (const)", - "MetadataLoadResult (type)", - "MetadataLoadResultSchema (const)", - "MetadataLoaderContract (type)", - "MetadataLoaderContractSchema (const)", "MetadataLock (type)", "MetadataLockSchema (const)", "MetadataLockSource (type)", @@ -1609,20 +1650,12 @@ "MetadataQueryResultSchema (const)", "MetadataQuerySchema (const)", "MetadataReadDecoration (type)", - "MetadataSaveOptions (type)", - "MetadataSaveOptionsSchema (const)", - "MetadataSaveResult (type)", - "MetadataSaveResultSchema (const)", - "MetadataStats (type)", - "MetadataStatsSchema (const)", "MetadataType (type)", "MetadataTypeRegistryEntry (type)", "MetadataTypeRegistryEntrySchema (const)", "MetadataTypeSchema (const)", "MetadataValidationResult (type)", "MetadataValidationResultSchema (const)", - "MetadataWatchEvent (type)", - "MetadataWatchEventSchema (const)", "MultiVersionSupport (type)", "MultiVersionSupportSchema (const)", "NamespaceConflictError (type)", @@ -2052,12 +2085,6 @@ "ApproverOrgSymbol (type)", "ApproverType (const)", "ApproverValueBinding (type)", - "AuthField (type)", - "AuthFieldSchema (const)", - "Authentication (type)", - "AuthenticationSchema (const)", - "AuthenticationType (type)", - "AuthenticationTypeSchema (const)", "BPMN_BOUNDARY_EVENT (const)", "BPMN_JOIN_GATEWAY (const)", "BPMN_PARALLEL_GATEWAY (const)", @@ -2086,16 +2113,6 @@ "ConcurrencyPolicySchema (const)", "ConflictResolution (type)", "ConflictResolutionSchema (const)", - "Connector (type)", - "ConnectorCategory (type)", - "ConnectorCategorySchema (const)", - "ConnectorInstance (type)", - "ConnectorInstanceSchema (const)", - "ConnectorOperation (type)", - "ConnectorOperationSchema (const)", - "ConnectorSchema (const)", - "ConnectorTrigger (type)", - "ConnectorTriggerSchema (const)", "CreateRecordConfig (type)", "CreateRecordConfigParsed (type)", "CreateRecordConfigSchema (const)", @@ -2218,14 +2235,8 @@ "NotifyConfig (type)", "NotifyConfigParsed (type)", "NotifyConfigSchema (const)", - "OAuth2Config (type)", - "OAuth2ConfigSchema (const)", "ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT (const)", - "OperationParameter (type)", - "OperationParameterSchema (const)", - "OperationType (type)", - "OperationTypeSchema (const)", "PARALLEL_NODE_TYPE (const)", "ParallelBranch (type)", "ParallelBranchSchema (const)", @@ -2236,17 +2247,16 @@ "ResolvedFlowNodeExpression (interface)", "RetryPolicy (type)", "RetryPolicySchema (const)", - "SCRIPT_BUILTIN_ACTION_TYPES (const)", - "SCRIPT_INVOKE_FUNCTION_ACTION_TYPE (const)", + "SCHEMALESS_NODE_CONFIG_SCHEMAS (const)", "ScheduleState (type)", "ScheduleStateParsed (type)", "ScheduleStateSchema (const)", + "SchemalessNodeType (type)", "ScreenConfig (type)", "ScreenConfigParsed (type)", "ScreenConfigSchema (const)", "ScreenFieldConfig (type)", "ScreenFieldConfigSchema (const)", - "ScriptBuiltinActionType (type)", "ScriptConfig (type)", "ScriptConfigParsed (type)", "ScriptConfigSchema (const)", @@ -2304,6 +2314,7 @@ "findRegionEntry (function)", "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", + "getSchemalessNodeConfigJsonSchemas (function)", "importBpmnToConstructs (function)", "isFlowFunctionEffect (function)", "normalizeControlFlowRegions (function)", @@ -2734,14 +2745,6 @@ "GetViewRequestSchema (const)", "GetViewResponse (type)", "GetViewResponseSchema (const)", - "GetWorkflowConfigRequest (type)", - "GetWorkflowConfigRequestSchema (const)", - "GetWorkflowConfigResponse (type)", - "GetWorkflowConfigResponseSchema (const)", - "GetWorkflowStateRequest (type)", - "GetWorkflowStateRequestSchema (const)", - "GetWorkflowStateResponse (type)", - "GetWorkflowStateResponseSchema (const)", "HandlerStatus (type)", "HandlerStatusSchema (const)", "HttpFindQueryParamsSchema (const)", @@ -3192,13 +3195,6 @@ "WebhookEventSchema (const)", "WellKnownCapabilities (type)", "WellKnownCapabilitiesSchema (const)", - "WorkflowProtocol (interface)", - "WorkflowState (type)", - "WorkflowStateSchema (const)", - "WorkflowTransitionRequest (type)", - "WorkflowTransitionRequestSchema (const)", - "WorkflowTransitionResponse (type)", - "WorkflowTransitionResponseSchema (const)", "envelopeViolations (function)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", @@ -3247,6 +3243,14 @@ "BreakpointColumnMapSchema (const)", "BreakpointName (type)", "BreakpointOrderMapSchema (const)", + "BulkActionDef (type)", + "BulkActionDefSchema (const)", + "BulkActionExecution (type)", + "BulkActionExecutionSchema (const)", + "BulkActionOperation (type)", + "BulkActionOperationSchema (const)", + "BulkActionParam (type)", + "BulkActionParamSchema (const)", "CHART_AGGREGATE_COMPARISON_SUFFIX (const)", "CalendarConfigSchema (const)", "ChartAggregate (type)", @@ -3473,6 +3477,9 @@ "PluralRuleSchema (const)", "REACT_BLOCKS (const)", "REACT_OVERLAY_SHADOWS (const)", + "REACT_RECORD_BLOCK_ALTERNATIVES (const)", + "RECORD_CONTEXT_BLOCK_TAGS (const)", + "RECORD_CONTEXT_TYPE_PREFIX (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", "ReactPropKind (type)", @@ -3604,9 +3611,11 @@ "expandViewContainer (function)", "expandViewContainerWithDiagnostics (function)", "isAggregatedViewContainer (function)", + "isRecordContextBlockType (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", + "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", "validateActionParams (function)", @@ -3752,7 +3761,6 @@ "IStorageService (interface)", "ITeamGraphService (interface)", "ITenantRouter (interface)", - "IWorkflowService (interface)", "ImportObjectOpts (interface)", "ImportObjectResult (interface)", "InboxListResult (interface)", @@ -3888,21 +3896,10 @@ "UploadArtifactResult (interface)", "UserModelMessage (type)", "ValidationResult (interface)", - "WorkflowStatus (interface)", - "WorkflowTransition (interface)", - "WorkflowTransitionResult (interface)", "WriteObservabilityOptions (interface)" ], "./integration": [ - "AckMode (type)", - "AckModeSchema (const)", - "ApiVersionConfig (type)", - "ApiVersionConfigSchema (const)", - "BuildConfig (type)", - "BuildConfigSchema (const)", "CONNECTOR_UPSTREAM_UNAVAILABLE (const)", - "CdcConfig (type)", - "CdcConfigSchema (const)", "CircuitBreakerConfig (type)", "CircuitBreakerConfigSchema (const)", "ConflictResolution (type)", @@ -3937,80 +3934,18 @@ "ConnectorType (type)", "ConnectorTypeSchema (const)", "ConnectorUpstreamUnavailableError (class)", - "ConsumerConfig (type)", - "ConsumerConfigSchema (const)", "DataSyncConfig (type)", "DataSyncConfigSchema (const)", - "DatabaseConnector (type)", - "DatabaseConnectorSchema (const)", - "DatabasePoolConfig (type)", - "DatabasePoolConfigSchema (const)", - "DatabaseProvider (type)", - "DatabaseProviderSchema (const)", - "DatabaseTable (type)", - "DatabaseTableSchema (const)", "DeclarativeConnectorEntry (type)", "DeclarativeConnectorEntrySchema (const)", - "DeliveryGuarantee (type)", - "DeliveryGuaranteeSchema (const)", - "DeploymentConfig (type)", - "DeploymentConfigSchema (const)", - "DlqConfig (type)", - "DlqConfigSchema (const)", - "DomainConfig (type)", - "DomainConfigSchema (const)", - "EdgeFunctionConfig (type)", - "EdgeFunctionConfigSchema (const)", - "EnvironmentVariables (type)", - "EnvironmentVariablesSchema (const)", "ErrorMappingConfig (type)", "ErrorMappingConfigSchema (const)", "ErrorMappingRule (type)", "ErrorMappingRuleSchema (const)", "FieldMapping (type)", "FieldMappingSchema (const)", - "FileAccessPattern (type)", - "FileAccessPatternSchema (const)", - "FileFilterConfig (type)", - "FileFilterConfigSchema (const)", - "FileMetadataConfig (type)", - "FileMetadataConfigSchema (const)", - "FileStorageConnector (type)", - "FileStorageConnectorSchema (const)", - "FileStorageProvider (type)", - "FileStorageProviderSchema (const)", - "FileVersioningConfig (type)", - "FileVersioningConfigSchema (const)", - "GitHubActionsWorkflow (type)", - "GitHubActionsWorkflowSchema (const)", - "GitHubCommitConfig (type)", - "GitHubCommitConfigSchema (const)", - "GitHubConnector (type)", - "GitHubConnectorSchema (const)", - "GitHubIssueTracking (type)", - "GitHubIssueTrackingSchema (const)", - "GitHubProvider (type)", - "GitHubProviderSchema (const)", - "GitHubPullRequestConfig (type)", - "GitHubPullRequestConfigSchema (const)", - "GitHubReleaseConfig (type)", - "GitHubReleaseConfigSchema (const)", - "GitHubRepository (type)", - "GitHubRepositorySchema (const)", - "GitRepositoryConfig (type)", - "GitRepositoryConfigSchema (const)", "HealthCheckConfig (type)", "HealthCheckConfigSchema (const)", - "MessageFormat (type)", - "MessageFormatSchema (const)", - "MessageQueueConnector (type)", - "MessageQueueConnectorSchema (const)", - "MessageQueueProvider (type)", - "MessageQueueProviderSchema (const)", - "MultipartUploadConfig (type)", - "MultipartUploadConfigSchema (const)", - "ProducerConfig (type)", - "ProducerConfigSchema (const)", "RateLimitConfig (type)", "RateLimitConfigSchema (const)", "RateLimitStrategy (type)", @@ -4018,57 +3953,16 @@ "ResolvedConnectorAuth (type)", "RetryConfig (type)", "RetryConfigSchema (const)", - "SaaSConnector (type)", - "SaasConnector (type)", - "SaasConnectorSchema (const)", - "SaasObjectType (type)", - "SaasObjectTypeSchema (const)", - "SaasProvider (type)", - "SaasProviderSchema (const)", - "SslConfig (type)", - "SslConfigSchema (const)", - "StorageBucket (type)", - "StorageBucketSchema (const)", "SyncStrategy (type)", "SyncStrategySchema (const)", - "TopicQueue (type)", - "TopicQueueSchema (const)", - "VercelConnector (type)", - "VercelConnectorSchema (const)", - "VercelFramework (type)", - "VercelFrameworkSchema (const)", - "VercelMonitoring (type)", - "VercelMonitoringSchema (const)", - "VercelProject (type)", - "VercelProjectSchema (const)", - "VercelProvider (type)", - "VercelProviderSchema (const)", - "VercelTeam (type)", - "VercelTeamSchema (const)", "WebhookConfig (type)", "WebhookConfigSchema (const)", "WebhookEvent (type)", "WebhookEventSchema (const)", "WebhookSignatureAlgorithm (type)", "WebhookSignatureAlgorithmSchema (const)", - "azureBlobConnectorExample (const)", "defineConnector (function)", - "githubEnterpriseConnectorExample (const)", - "githubPublicConnectorExample (const)", - "googleDriveConnectorExample (const)", - "hubspotConnectorExample (const)", - "isConnectorUpstreamUnavailable (function)", - "kafkaConnectorExample (const)", - "mongoConnectorExample (const)", - "postgresConnectorExample (const)", - "pubsubConnectorExample (const)", - "rabbitmqConnectorExample (const)", - "s3ConnectorExample (const)", - "salesforceConnectorExample (const)", - "snowflakeConnectorExample (const)", - "sqsConnectorExample (const)", - "vercelNextJsConnectorExample (const)", - "vercelStaticSiteConnectorExample (const)" + "isConnectorUpstreamUnavailable (function)" ], "./security": [ "AccessMatrix (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index fb905f2dd6..73aeed3b87 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -589,7 +589,6 @@ "api/ApiRoutes:realtime", "api/ApiRoutes:storage", "api/ApiRoutes:ui", - "api/ApiRoutes:workflow", "api/ApiTestCollection:description", "api/ApiTestCollection:folders", "api/ApiTestCollection:name", @@ -1205,14 +1204,6 @@ "api/GetViewRequest:viewId", "api/GetViewResponse:object", "api/GetViewResponse:view", - "api/GetWorkflowConfigRequest:object", - "api/GetWorkflowConfigResponse:object", - "api/GetWorkflowConfigResponse:workflows", - "api/GetWorkflowStateRequest:object", - "api/GetWorkflowStateRequest:recordId", - "api/GetWorkflowStateResponse:object", - "api/GetWorkflowStateResponse:recordId", - "api/GetWorkflowStateResponse:state", "api/HttpFindQueryParams:count", "api/HttpFindQueryParams:distinct [RETIRED]", "api/HttpFindQueryParams:expand", @@ -2121,18 +2112,6 @@ "api/WellKnownCapabilities:export", "api/WellKnownCapabilities:search", "api/WellKnownCapabilities:transactionalBatch", - "api/WorkflowState:availableTransitions", - "api/WorkflowState:currentState", - "api/WorkflowState:history", - "api/WorkflowTransitionRequest:comment", - "api/WorkflowTransitionRequest:data", - "api/WorkflowTransitionRequest:object", - "api/WorkflowTransitionRequest:recordId", - "api/WorkflowTransitionRequest:transition", - "api/WorkflowTransitionResponse:object", - "api/WorkflowTransitionResponse:recordId", - "api/WorkflowTransitionResponse:state", - "api/WorkflowTransitionResponse:success", "automation/ActionDescriptor:aliasOf", "automation/ActionDescriptor:category", "automation/ActionDescriptor:configSchema", @@ -2171,18 +2150,6 @@ "automation/ApprovalNodeConfig:maxRevisions", "automation/ApprovalNodeConfig:minApprovals", "automation/ApprovalNodeConfig:onEmptyApprovers", - "automation/AuthField:default", - "automation/AuthField:description", - "automation/AuthField:label", - "automation/AuthField:name", - "automation/AuthField:options", - "automation/AuthField:placeholder", - "automation/AuthField:required", - "automation/AuthField:type", - "automation/Authentication:fields", - "automation/Authentication:oauth2", - "automation/Authentication:test", - "automation/Authentication:type", "automation/BpmnDiagnostic:bpmnElementId", "automation/BpmnDiagnostic:message", "automation/BpmnDiagnostic:nodeId", @@ -2220,50 +2187,6 @@ "automation/ConcurrencyPolicy:maxConcurrent", "automation/ConcurrencyPolicy:onConflict", "automation/ConcurrencyPolicy:queueTimeoutMs", - "automation/Connector:authentication", - "automation/Connector:author", - "automation/Connector:baseUrl", - "automation/Connector:category", - "automation/Connector:description", - "automation/Connector:documentation", - "automation/Connector:homepage", - "automation/Connector:icon", - "automation/Connector:id", - "automation/Connector:license", - "automation/Connector:metadata", - "automation/Connector:name", - "automation/Connector:operations", - "automation/Connector:rateLimit", - "automation/Connector:tags", - "automation/Connector:triggers", - "automation/Connector:verified", - "automation/Connector:version", - "automation/ConnectorInstance:active", - "automation/ConnectorInstance:config", - "automation/ConnectorInstance:connectorId", - "automation/ConnectorInstance:createdAt", - "automation/ConnectorInstance:credentials", - "automation/ConnectorInstance:description", - "automation/ConnectorInstance:id", - "automation/ConnectorInstance:lastTestedAt", - "automation/ConnectorInstance:name", - "automation/ConnectorInstance:testStatus", - "automation/ConnectorOperation:description", - "automation/ConnectorOperation:id", - "automation/ConnectorOperation:inputSchema", - "automation/ConnectorOperation:name", - "automation/ConnectorOperation:outputSchema", - "automation/ConnectorOperation:sampleOutput", - "automation/ConnectorOperation:supportsFiltering", - "automation/ConnectorOperation:supportsPagination", - "automation/ConnectorOperation:type", - "automation/ConnectorTrigger:config", - "automation/ConnectorTrigger:description", - "automation/ConnectorTrigger:id", - "automation/ConnectorTrigger:name", - "automation/ConnectorTrigger:outputSchema", - "automation/ConnectorTrigger:pollingIntervalMs", - "automation/ConnectorTrigger:type", "automation/CreateRecordConfig:fields", "automation/CreateRecordConfig:objectName", "automation/CreateRecordConfig:outputVariable", @@ -2509,19 +2432,6 @@ "automation/NotifyConfig:sourceObject", "automation/NotifyConfig:title", "automation/NotifyConfig:topic", - "automation/OAuth2Config:authorizationUrl", - "automation/OAuth2Config:clientIdField", - "automation/OAuth2Config:clientSecretField", - "automation/OAuth2Config:scopes", - "automation/OAuth2Config:tokenUrl", - "automation/OperationParameter:default", - "automation/OperationParameter:description", - "automation/OperationParameter:dynamicOptions", - "automation/OperationParameter:label", - "automation/OperationParameter:name", - "automation/OperationParameter:required", - "automation/OperationParameter:type", - "automation/OperationParameter:validation", "automation/ParallelBranch:edges", "automation/ParallelBranch:name", "automation/ParallelBranch:nodes", @@ -2565,14 +2475,14 @@ "automation/ScreenFieldConfig:required", "automation/ScreenFieldConfig:type", "automation/ScreenFieldConfig:visibleWhen", - "automation/ScriptConfig:actionType", + "automation/ScriptConfig:actionType [RETIRED]", "automation/ScriptConfig:function", "automation/ScriptConfig:inputs", "automation/ScriptConfig:outputVariable", - "automation/ScriptConfig:recipients", - "automation/ScriptConfig:script", - "automation/ScriptConfig:template", - "automation/ScriptConfig:variables", + "automation/ScriptConfig:recipients [RETIRED]", + "automation/ScriptConfig:script [RETIRED]", + "automation/ScriptConfig:template [RETIRED]", + "automation/ScriptConfig:variables [RETIRED]", "automation/StateMachine:contextSchema", "automation/StateMachine:description", "automation/StateMachine:id", @@ -3184,6 +3094,10 @@ "data/AnalyticsQuery:timeDimensions", "data/AnalyticsQuery:timezone", "data/AnalyticsQuery:where", + "data/AutoPersistenceConfig:autoSaveInterval", + "data/AutoPersistenceConfig:key", + "data/AutoPersistenceConfig:path", + "data/AutoPersistenceConfig:type", "data/BaseEngineOptions:context", "data/ConditionalValidation:active", "data/ConditionalValidation:description", @@ -3297,6 +3211,13 @@ "data/DataTypeMapping:number", "data/DataTypeMapping:text", "data/DataTypeMapping:uuid", + "data/Datasource:_lock", + "data/Datasource:_lockDocsUrl", + "data/Datasource:_lockReason", + "data/Datasource:_lockSource", + "data/Datasource:_packageId", + "data/Datasource:_packageVersion", + "data/Datasource:_provenance", "data/Datasource:active", "data/Datasource:autoConnect", "data/Datasource:capabilities", @@ -3309,7 +3230,6 @@ "data/Datasource:name", "data/Datasource:origin", "data/Datasource:pool", - "data/Datasource:readReplicas", "data/Datasource:retryPolicy", "data/Datasource:schemaMode", "data/Datasource:ssl", @@ -3560,6 +3480,9 @@ "data/FieldMapping:target", "data/FieldMapping:transform", "data/FieldReference:$field", + "data/FilePersistenceConfig:autoSaveInterval", + "data/FilePersistenceConfig:path", + "data/FilePersistenceConfig:type", "data/FileValue:alt", "data/FileValue:duration", "data/FileValue:mimeType", @@ -3622,6 +3545,8 @@ "data/Lifecycle:retention", "data/Lifecycle:storage", "data/Lifecycle:ttl", + "data/LocalStoragePersistenceConfig:key", + "data/LocalStoragePersistenceConfig:type", "data/LocationCoordinates:accuracy", "data/LocationCoordinates:altitude", "data/LocationCoordinates:latitude", @@ -3647,6 +3572,22 @@ "data/Metric:name", "data/Metric:sql", "data/Metric:type", + "data/MongoConfig:authSource", + "data/MongoConfig:database", + "data/MongoConfig:host", + "data/MongoConfig:options", + "data/MongoConfig:password", + "data/MongoConfig:port", + "data/MongoConfig:url", + "data/MongoConfig:username", + "data/MysqlConfig:autoMigrate", + "data/MysqlConfig:database", + "data/MysqlConfig:host", + "data/MysqlConfig:password", + "data/MysqlConfig:port", + "data/MysqlConfig:ssl", + "data/MysqlConfig:url", + "data/MysqlConfig:username", "data/NoSQLDataTypeMapping:array", "data/NoSQLDataTypeMapping:binary", "data/NoSQLDataTypeMapping:boolean", @@ -3782,6 +3723,17 @@ "data/PoolConfig:idleTimeoutMillis", "data/PoolConfig:max", "data/PoolConfig:min", + "data/PostgresConfig:applicationName", + "data/PostgresConfig:autoMigrate", + "data/PostgresConfig:database", + "data/PostgresConfig:host", + "data/PostgresConfig:password", + "data/PostgresConfig:port", + "data/PostgresConfig:schema", + "data/PostgresConfig:ssl", + "data/PostgresConfig:statementTimeout", + "data/PostgresConfig:url", + "data/PostgresConfig:username", "data/Query:aggregations", "data/Query:cursor [RETIRED]", "data/Query:distinct [RETIRED]", @@ -3849,6 +3801,13 @@ "data/ScriptValidation:severity", "data/ScriptValidation:tags", "data/ScriptValidation:type", + "data/Seed:_lock", + "data/Seed:_lockDocsUrl", + "data/Seed:_lockReason", + "data/Seed:_lockSource", + "data/Seed:_packageId", + "data/Seed:_packageVersion", + "data/Seed:_provenance", "data/Seed:env", "data/Seed:externalId", "data/Seed:mode", @@ -3899,6 +3858,10 @@ "data/SortNode:order", "data/SpecialOperator:$exists", "data/SpecialOperator:$null", + "data/SqliteConfig:autoMigrate", + "data/SqliteConfig:filename", + "data/SqliteWasmConfig:filename", + "data/SqliteWasmConfig:persist", "data/StateMachineValidation:active", "data/StateMachineValidation:description", "data/StateMachineValidation:events", @@ -4114,23 +4077,6 @@ "identity/VerificationToken:expires", "identity/VerificationToken:identifier", "identity/VerificationToken:token", - "integration/ApiVersionConfig:deprecationDate", - "integration/ApiVersionConfig:isDefault", - "integration/ApiVersionConfig:sunsetDate", - "integration/ApiVersionConfig:version", - "integration/BuildConfig:buildCommand", - "integration/BuildConfig:devCommand", - "integration/BuildConfig:env", - "integration/BuildConfig:installCommand", - "integration/BuildConfig:nodeVersion", - "integration/BuildConfig:outputDirectory", - "integration/CdcConfig:batchSize", - "integration/CdcConfig:enabled", - "integration/CdcConfig:method", - "integration/CdcConfig:pollIntervalMs", - "integration/CdcConfig:publicationName", - "integration/CdcConfig:slotName", - "integration/CdcConfig:startPosition", "integration/CircuitBreakerConfig:enabled", "integration/CircuitBreakerConfig:failureThreshold", "integration/CircuitBreakerConfig:fallbackStrategy", @@ -4182,15 +4128,6 @@ "integration/ConnectorTrigger:key", "integration/ConnectorTrigger:label", "integration/ConnectorTrigger:type", - "integration/ConsumerConfig:ackMode", - "integration/ConsumerConfig:autoCommit", - "integration/ConsumerConfig:autoCommitIntervalMs", - "integration/ConsumerConfig:concurrency", - "integration/ConsumerConfig:consumerGroup", - "integration/ConsumerConfig:enabled", - "integration/ConsumerConfig:prefetchCount", - "integration/ConsumerConfig:rebalanceTimeoutMs", - "integration/ConsumerConfig:sessionTimeoutMs", "integration/DataSyncConfig:batchSize", "integration/DataSyncConfig:conflictResolution", "integration/DataSyncConfig:deleteMode", @@ -4200,52 +4137,6 @@ "integration/DataSyncConfig:schedule", "integration/DataSyncConfig:strategy", "integration/DataSyncConfig:timestampField", - "integration/DatabaseConnector:actions", - "integration/DatabaseConnector:auth", - "integration/DatabaseConnector:authentication", - "integration/DatabaseConnector:cdcConfig", - "integration/DatabaseConnector:connectionConfig", - "integration/DatabaseConnector:connectionTimeoutMs", - "integration/DatabaseConnector:description", - "integration/DatabaseConnector:enableQueryLogging", - "integration/DatabaseConnector:enabled", - "integration/DatabaseConnector:errorMapping", - "integration/DatabaseConnector:fieldMappings", - "integration/DatabaseConnector:health", - "integration/DatabaseConnector:icon", - "integration/DatabaseConnector:label", - "integration/DatabaseConnector:metadata", - "integration/DatabaseConnector:name", - "integration/DatabaseConnector:poolConfig", - "integration/DatabaseConnector:provider", - "integration/DatabaseConnector:providerConfig", - "integration/DatabaseConnector:queryTimeoutMs", - "integration/DatabaseConnector:rateLimitConfig", - "integration/DatabaseConnector:readReplicaConfig", - "integration/DatabaseConnector:requestTimeoutMs", - "integration/DatabaseConnector:retryConfig", - "integration/DatabaseConnector:sslConfig", - "integration/DatabaseConnector:status", - "integration/DatabaseConnector:syncConfig", - "integration/DatabaseConnector:tables", - "integration/DatabaseConnector:triggers", - "integration/DatabaseConnector:type", - "integration/DatabaseConnector:webhooks", - "integration/DatabasePoolConfig:acquireTimeoutMs", - "integration/DatabasePoolConfig:connectionTimeoutMs", - "integration/DatabasePoolConfig:evictionRunIntervalMs", - "integration/DatabasePoolConfig:idleTimeoutMs", - "integration/DatabasePoolConfig:max", - "integration/DatabasePoolConfig:min", - "integration/DatabasePoolConfig:testOnBorrow", - "integration/DatabaseTable:enabled", - "integration/DatabaseTable:fieldMappings", - "integration/DatabaseTable:label", - "integration/DatabaseTable:name", - "integration/DatabaseTable:primaryKey", - "integration/DatabaseTable:schema", - "integration/DatabaseTable:tableName", - "integration/DatabaseTable:whereClause", "integration/DeclarativeConnectorEntry:actions", "integration/DeclarativeConnectorEntry:auth", "integration/DeclarativeConnectorEntry:authentication", @@ -4269,30 +4160,6 @@ "integration/DeclarativeConnectorEntry:triggers", "integration/DeclarativeConnectorEntry:type", "integration/DeclarativeConnectorEntry:webhooks", - "integration/DeploymentConfig:autoDeployment", - "integration/DeploymentConfig:deployHooks", - "integration/DeploymentConfig:enablePreview", - "integration/DeploymentConfig:previewComments", - "integration/DeploymentConfig:productionProtection", - "integration/DeploymentConfig:regions", - "integration/DlqConfig:enabled", - "integration/DlqConfig:maxRetries", - "integration/DlqConfig:queueName", - "integration/DlqConfig:retryDelayMs", - "integration/DomainConfig:customCertificate", - "integration/DomainConfig:domain", - "integration/DomainConfig:gitBranch", - "integration/DomainConfig:httpsRedirect", - "integration/EdgeFunctionConfig:memoryLimit", - "integration/EdgeFunctionConfig:name", - "integration/EdgeFunctionConfig:path", - "integration/EdgeFunctionConfig:regions", - "integration/EdgeFunctionConfig:timeout", - "integration/EnvironmentVariables:gitBranch", - "integration/EnvironmentVariables:isSecret", - "integration/EnvironmentVariables:key", - "integration/EnvironmentVariables:target", - "integration/EnvironmentVariables:value", "integration/ErrorMappingConfig:defaultCategory", "integration/ErrorMappingConfig:logUnmapped", "integration/ErrorMappingConfig:rules", @@ -4311,123 +4178,6 @@ "integration/FieldMapping:syncMode", "integration/FieldMapping:target", "integration/FieldMapping:transform", - "integration/FileFilterConfig:allowedExtensions", - "integration/FileFilterConfig:blockedExtensions", - "integration/FileFilterConfig:excludePatterns", - "integration/FileFilterConfig:includePatterns", - "integration/FileFilterConfig:maxFileSize", - "integration/FileFilterConfig:minFileSize", - "integration/FileMetadataConfig:customMetadata", - "integration/FileMetadataConfig:extractMetadata", - "integration/FileMetadataConfig:metadataFields", - "integration/FileStorageConnector:actions", - "integration/FileStorageConnector:auth", - "integration/FileStorageConnector:authentication", - "integration/FileStorageConnector:buckets", - "integration/FileStorageConnector:bufferSize", - "integration/FileStorageConnector:connectionTimeoutMs", - "integration/FileStorageConnector:contentProcessing", - "integration/FileStorageConnector:description", - "integration/FileStorageConnector:enabled", - "integration/FileStorageConnector:encryption", - "integration/FileStorageConnector:errorMapping", - "integration/FileStorageConnector:fieldMappings", - "integration/FileStorageConnector:health", - "integration/FileStorageConnector:icon", - "integration/FileStorageConnector:label", - "integration/FileStorageConnector:lifecyclePolicy", - "integration/FileStorageConnector:metadata", - "integration/FileStorageConnector:metadataConfig", - "integration/FileStorageConnector:multipartConfig", - "integration/FileStorageConnector:name", - "integration/FileStorageConnector:provider", - "integration/FileStorageConnector:providerConfig", - "integration/FileStorageConnector:rateLimitConfig", - "integration/FileStorageConnector:requestTimeoutMs", - "integration/FileStorageConnector:retryConfig", - "integration/FileStorageConnector:status", - "integration/FileStorageConnector:storageConfig", - "integration/FileStorageConnector:syncConfig", - "integration/FileStorageConnector:transferAcceleration", - "integration/FileStorageConnector:triggers", - "integration/FileStorageConnector:type", - "integration/FileStorageConnector:versioningConfig", - "integration/FileStorageConnector:webhooks", - "integration/FileVersioningConfig:enabled", - "integration/FileVersioningConfig:maxVersions", - "integration/FileVersioningConfig:retentionDays", - "integration/GitHubActionsWorkflow:enabled", - "integration/GitHubActionsWorkflow:env", - "integration/GitHubActionsWorkflow:name", - "integration/GitHubActionsWorkflow:path", - "integration/GitHubActionsWorkflow:secrets", - "integration/GitHubActionsWorkflow:triggers", - "integration/GitHubCommitConfig:authorEmail", - "integration/GitHubCommitConfig:authorName", - "integration/GitHubCommitConfig:messageTemplate", - "integration/GitHubCommitConfig:signCommits", - "integration/GitHubCommitConfig:useConventionalCommits", - "integration/GitHubConnector:actions", - "integration/GitHubConnector:auth", - "integration/GitHubConnector:authentication", - "integration/GitHubConnector:baseUrl", - "integration/GitHubConnector:commitConfig", - "integration/GitHubConnector:connectionTimeoutMs", - "integration/GitHubConnector:description", - "integration/GitHubConnector:enableWebhooks", - "integration/GitHubConnector:enabled", - "integration/GitHubConnector:errorMapping", - "integration/GitHubConnector:fieldMappings", - "integration/GitHubConnector:health", - "integration/GitHubConnector:icon", - "integration/GitHubConnector:issueTracking", - "integration/GitHubConnector:label", - "integration/GitHubConnector:metadata", - "integration/GitHubConnector:name", - "integration/GitHubConnector:provider", - "integration/GitHubConnector:providerConfig", - "integration/GitHubConnector:pullRequestConfig", - "integration/GitHubConnector:rateLimitConfig", - "integration/GitHubConnector:releaseConfig", - "integration/GitHubConnector:repositories", - "integration/GitHubConnector:requestTimeoutMs", - "integration/GitHubConnector:retryConfig", - "integration/GitHubConnector:status", - "integration/GitHubConnector:syncConfig", - "integration/GitHubConnector:triggers", - "integration/GitHubConnector:type", - "integration/GitHubConnector:webhookEvents", - "integration/GitHubConnector:webhooks", - "integration/GitHubConnector:workflows", - "integration/GitHubIssueTracking:autoAssign", - "integration/GitHubIssueTracking:autoCloseStale", - "integration/GitHubIssueTracking:defaultLabels", - "integration/GitHubIssueTracking:enabled", - "integration/GitHubIssueTracking:templatePaths", - "integration/GitHubPullRequestConfig:bodyTemplate", - "integration/GitHubPullRequestConfig:defaultAssignees", - "integration/GitHubPullRequestConfig:defaultLabels", - "integration/GitHubPullRequestConfig:defaultReviewers", - "integration/GitHubPullRequestConfig:deleteHeadBranch", - "integration/GitHubPullRequestConfig:draftByDefault", - "integration/GitHubPullRequestConfig:titleTemplate", - "integration/GitHubReleaseConfig:autoReleaseNotes", - "integration/GitHubReleaseConfig:draftByDefault", - "integration/GitHubReleaseConfig:preReleasePattern", - "integration/GitHubReleaseConfig:releaseNameTemplate", - "integration/GitHubReleaseConfig:semanticVersioning", - "integration/GitHubReleaseConfig:tagPattern", - "integration/GitHubRepository:autoMerge", - "integration/GitHubRepository:branchProtection", - "integration/GitHubRepository:defaultBranch", - "integration/GitHubRepository:name", - "integration/GitHubRepository:owner", - "integration/GitHubRepository:topics", - "integration/GitRepositoryConfig:autoDeployPreview", - "integration/GitRepositoryConfig:autoDeployProduction", - "integration/GitRepositoryConfig:productionBranch", - "integration/GitRepositoryConfig:repo", - "integration/GitRepositoryConfig:type", "integration/HealthCheckConfig:enabled", "integration/HealthCheckConfig:endpoint", "integration/HealthCheckConfig:expectedStatus", @@ -4436,51 +4186,6 @@ "integration/HealthCheckConfig:method", "integration/HealthCheckConfig:timeoutMs", "integration/HealthCheckConfig:unhealthyThreshold", - "integration/MessageQueueConnector:actions", - "integration/MessageQueueConnector:auth", - "integration/MessageQueueConnector:authentication", - "integration/MessageQueueConnector:brokerConfig", - "integration/MessageQueueConnector:connectionTimeoutMs", - "integration/MessageQueueConnector:deliveryGuarantee", - "integration/MessageQueueConnector:description", - "integration/MessageQueueConnector:enableMetrics", - "integration/MessageQueueConnector:enableTracing", - "integration/MessageQueueConnector:enabled", - "integration/MessageQueueConnector:errorMapping", - "integration/MessageQueueConnector:fieldMappings", - "integration/MessageQueueConnector:health", - "integration/MessageQueueConnector:icon", - "integration/MessageQueueConnector:label", - "integration/MessageQueueConnector:metadata", - "integration/MessageQueueConnector:name", - "integration/MessageQueueConnector:preserveOrder", - "integration/MessageQueueConnector:provider", - "integration/MessageQueueConnector:providerConfig", - "integration/MessageQueueConnector:rateLimitConfig", - "integration/MessageQueueConnector:requestTimeoutMs", - "integration/MessageQueueConnector:retryConfig", - "integration/MessageQueueConnector:saslConfig", - "integration/MessageQueueConnector:schemaRegistry", - "integration/MessageQueueConnector:sslConfig", - "integration/MessageQueueConnector:status", - "integration/MessageQueueConnector:syncConfig", - "integration/MessageQueueConnector:topics", - "integration/MessageQueueConnector:triggers", - "integration/MessageQueueConnector:type", - "integration/MessageQueueConnector:webhooks", - "integration/MultipartUploadConfig:enabled", - "integration/MultipartUploadConfig:maxConcurrentParts", - "integration/MultipartUploadConfig:partSize", - "integration/MultipartUploadConfig:threshold", - "integration/ProducerConfig:acks", - "integration/ProducerConfig:batchSize", - "integration/ProducerConfig:compressionType", - "integration/ProducerConfig:enabled", - "integration/ProducerConfig:idempotence", - "integration/ProducerConfig:lingerMs", - "integration/ProducerConfig:maxInFlightRequests", - "integration/ProducerConfig:transactionTimeoutMs", - "integration/ProducerConfig:transactional", "integration/RateLimitConfig:burstCapacity", "integration/RateLimitConfig:maxRequests", "integration/RateLimitConfig:rateLimitHeaders", @@ -4495,113 +4200,6 @@ "integration/RetryConfig:retryOnNetworkError", "integration/RetryConfig:retryableStatusCodes", "integration/RetryConfig:strategy", - "integration/SaasConnector:actions", - "integration/SaasConnector:apiVersion", - "integration/SaasConnector:auth", - "integration/SaasConnector:authentication", - "integration/SaasConnector:baseUrl", - "integration/SaasConnector:connectionTimeoutMs", - "integration/SaasConnector:customHeaders", - "integration/SaasConnector:description", - "integration/SaasConnector:enabled", - "integration/SaasConnector:errorMapping", - "integration/SaasConnector:fieldMappings", - "integration/SaasConnector:health", - "integration/SaasConnector:icon", - "integration/SaasConnector:label", - "integration/SaasConnector:metadata", - "integration/SaasConnector:name", - "integration/SaasConnector:oauthSettings", - "integration/SaasConnector:objectTypes", - "integration/SaasConnector:paginationConfig", - "integration/SaasConnector:provider", - "integration/SaasConnector:providerConfig", - "integration/SaasConnector:rateLimitConfig", - "integration/SaasConnector:requestTimeoutMs", - "integration/SaasConnector:retryConfig", - "integration/SaasConnector:sandboxConfig", - "integration/SaasConnector:status", - "integration/SaasConnector:syncConfig", - "integration/SaasConnector:triggers", - "integration/SaasConnector:type", - "integration/SaasConnector:webhooks", - "integration/SaasObjectType:apiName", - "integration/SaasObjectType:enabled", - "integration/SaasObjectType:fieldMappings", - "integration/SaasObjectType:label", - "integration/SaasObjectType:name", - "integration/SaasObjectType:supportsCreate", - "integration/SaasObjectType:supportsDelete", - "integration/SaasObjectType:supportsUpdate", - "integration/SslConfig:ca", - "integration/SslConfig:cert", - "integration/SslConfig:enabled", - "integration/SslConfig:key", - "integration/SslConfig:rejectUnauthorized", - "integration/StorageBucket:accessPattern", - "integration/StorageBucket:bucketName", - "integration/StorageBucket:enabled", - "integration/StorageBucket:fileFilters", - "integration/StorageBucket:label", - "integration/StorageBucket:name", - "integration/StorageBucket:prefix", - "integration/StorageBucket:region", - "integration/TopicQueue:consumerConfig", - "integration/TopicQueue:dlqConfig", - "integration/TopicQueue:enabled", - "integration/TopicQueue:label", - "integration/TopicQueue:messageFilter", - "integration/TopicQueue:messageFormat", - "integration/TopicQueue:mode", - "integration/TopicQueue:name", - "integration/TopicQueue:partitions", - "integration/TopicQueue:producerConfig", - "integration/TopicQueue:replicationFactor", - "integration/TopicQueue:routingKey", - "integration/TopicQueue:topicName", - "integration/VercelConnector:actions", - "integration/VercelConnector:auth", - "integration/VercelConnector:authentication", - "integration/VercelConnector:baseUrl", - "integration/VercelConnector:connectionTimeoutMs", - "integration/VercelConnector:description", - "integration/VercelConnector:enableWebhooks", - "integration/VercelConnector:enabled", - "integration/VercelConnector:errorMapping", - "integration/VercelConnector:fieldMappings", - "integration/VercelConnector:health", - "integration/VercelConnector:icon", - "integration/VercelConnector:label", - "integration/VercelConnector:metadata", - "integration/VercelConnector:monitoring", - "integration/VercelConnector:name", - "integration/VercelConnector:projects", - "integration/VercelConnector:provider", - "integration/VercelConnector:providerConfig", - "integration/VercelConnector:rateLimitConfig", - "integration/VercelConnector:requestTimeoutMs", - "integration/VercelConnector:retryConfig", - "integration/VercelConnector:status", - "integration/VercelConnector:syncConfig", - "integration/VercelConnector:team", - "integration/VercelConnector:triggers", - "integration/VercelConnector:type", - "integration/VercelConnector:webhookEvents", - "integration/VercelConnector:webhooks", - "integration/VercelMonitoring:enableSpeedInsights", - "integration/VercelMonitoring:enableWebAnalytics", - "integration/VercelMonitoring:logDrains", - "integration/VercelProject:buildConfig", - "integration/VercelProject:deploymentConfig", - "integration/VercelProject:domains", - "integration/VercelProject:edgeFunctions", - "integration/VercelProject:environmentVariables", - "integration/VercelProject:framework", - "integration/VercelProject:gitRepository", - "integration/VercelProject:name", - "integration/VercelProject:rootDirectory", - "integration/VercelTeam:teamId", - "integration/VercelTeam:teamName", "integration/WebhookConfig:description", "integration/WebhookConfig:events", "integration/WebhookConfig:headers", @@ -4992,12 +4590,6 @@ "kernel/MetadataBulkResult:failed", "kernel/MetadataBulkResult:succeeded", "kernel/MetadataBulkResult:total", - "kernel/MetadataCollectionInfo:count", - "kernel/MetadataCollectionInfo:formats", - "kernel/MetadataCollectionInfo:lastModified", - "kernel/MetadataCollectionInfo:location", - "kernel/MetadataCollectionInfo:totalSize", - "kernel/MetadataCollectionInfo:type", "kernel/MetadataDependency:kind", "kernel/MetadataDependency:sourceName", "kernel/MetadataDependency:sourceType", @@ -5017,38 +4609,6 @@ "kernel/MetadataEvent:packageId", "kernel/MetadataEvent:payload", "kernel/MetadataEvent:timestamp", - "kernel/MetadataExportOptions:compress", - "kernel/MetadataExportOptions:filter", - "kernel/MetadataExportOptions:format", - "kernel/MetadataExportOptions:includeStats", - "kernel/MetadataExportOptions:output", - "kernel/MetadataExportOptions:prettify", - "kernel/MetadataImportOptions:conflictResolution", - "kernel/MetadataImportOptions:continueOnError", - "kernel/MetadataImportOptions:dryRun", - "kernel/MetadataImportOptions:transform", - "kernel/MetadataImportOptions:validate", - "kernel/MetadataLoadOptions:filter", - "kernel/MetadataLoadOptions:ifModifiedSince", - "kernel/MetadataLoadOptions:ifNoneMatch", - "kernel/MetadataLoadOptions:limit", - "kernel/MetadataLoadOptions:patterns", - "kernel/MetadataLoadOptions:recursive", - "kernel/MetadataLoadOptions:useCache", - "kernel/MetadataLoadOptions:validate", - "kernel/MetadataLoadResult:data", - "kernel/MetadataLoadResult:etag", - "kernel/MetadataLoadResult:fromCache", - "kernel/MetadataLoadResult:loadTime", - "kernel/MetadataLoadResult:notModified", - "kernel/MetadataLoadResult:stats", - "kernel/MetadataLoaderContract:capabilities", - "kernel/MetadataLoaderContract:name", - "kernel/MetadataLoaderContract:protocol", - "kernel/MetadataLoaderContract:supportedFormats", - "kernel/MetadataLoaderContract:supportsCache", - "kernel/MetadataLoaderContract:supportsWatch", - "kernel/MetadataLoaderContract:supportsWrite", "kernel/MetadataManagerConfig:cache", "kernel/MetadataManagerConfig:datasource", "kernel/MetadataManagerConfig:fallback", @@ -5106,27 +4666,6 @@ "kernel/MetadataQueryResult:page", "kernel/MetadataQueryResult:pageSize", "kernel/MetadataQueryResult:total", - "kernel/MetadataSaveOptions:atomic", - "kernel/MetadataSaveOptions:backup", - "kernel/MetadataSaveOptions:format", - "kernel/MetadataSaveOptions:includeDefaults", - "kernel/MetadataSaveOptions:indent", - "kernel/MetadataSaveOptions:overwrite", - "kernel/MetadataSaveOptions:path", - "kernel/MetadataSaveOptions:prettify", - "kernel/MetadataSaveOptions:sortKeys", - "kernel/MetadataSaveResult:backupPath", - "kernel/MetadataSaveResult:etag", - "kernel/MetadataSaveResult:path", - "kernel/MetadataSaveResult:saveTime", - "kernel/MetadataSaveResult:size", - "kernel/MetadataSaveResult:success", - "kernel/MetadataStats:etag", - "kernel/MetadataStats:format", - "kernel/MetadataStats:metadata", - "kernel/MetadataStats:modifiedAt", - "kernel/MetadataStats:path", - "kernel/MetadataStats:size", "kernel/MetadataTypeRegistryEntry:actions", "kernel/MetadataTypeRegistryEntry:allowOrgOverride", "kernel/MetadataTypeRegistryEntry:allowRuntimeCreate", @@ -5142,12 +4681,6 @@ "kernel/MetadataValidationResult:errors", "kernel/MetadataValidationResult:valid", "kernel/MetadataValidationResult:warnings", - "kernel/MetadataWatchEvent:data", - "kernel/MetadataWatchEvent:metadataType", - "kernel/MetadataWatchEvent:name", - "kernel/MetadataWatchEvent:path", - "kernel/MetadataWatchEvent:timestamp", - "kernel/MetadataWatchEvent:type", "kernel/MultiVersionSupport:enabled", "kernel/MultiVersionSupport:maxConcurrentVersions", "kernel/MultiVersionSupport:rollout", @@ -6149,6 +5682,13 @@ "system/BatchProgress:status", "system/BatchProgress:succeeded", "system/BatchProgress:total", + "system/Book:_lock", + "system/Book:_lockDocsUrl", + "system/Book:_lockReason", + "system/Book:_lockSource", + "system/Book:_packageId", + "system/Book:_packageVersion", + "system/Book:_provenance", "system/Book:audience", "system/Book:description", "system/Book:groups", @@ -6372,6 +5912,13 @@ "system/DistributedCacheConfig:prefetch", "system/DistributedCacheConfig:tiers", "system/DistributedCacheConfig:warmup", + "system/Doc:_lock", + "system/Doc:_lockDocsUrl", + "system/Doc:_lockReason", + "system/Doc:_lockSource", + "system/Doc:_packageId", + "system/Doc:_packageVersion", + "system/Doc:_provenance", "system/Doc:content", "system/Doc:description", "system/Doc:group", @@ -6596,6 +6143,13 @@ "system/IncidentResponsePolicy:triageDeadlineHours", "system/IntervalSchedule:intervalMs", "system/IntervalSchedule:type", + "system/Job:_lock", + "system/Job:_lockDocsUrl", + "system/Job:_lockReason", + "system/Job:_lockSource", + "system/Job:_packageId", + "system/Job:_packageVersion", + "system/Job:_provenance", "system/Job:description", "system/Job:enabled", "system/Job:handler", @@ -7627,6 +7181,30 @@ "ui/BreakpointOrderMap:sm", "ui/BreakpointOrderMap:xl", "ui/BreakpointOrderMap:xs", + "ui/BulkActionDef:batchSize", + "ui/BulkActionDef:confirmLabel", + "ui/BulkActionDef:confirmText", + "ui/BulkActionDef:execution", + "ui/BulkActionDef:icon", + "ui/BulkActionDef:label", + "ui/BulkActionDef:maxRecords", + "ui/BulkActionDef:name", + "ui/BulkActionDef:operation", + "ui/BulkActionDef:params", + "ui/BulkActionDef:patch", + "ui/BulkActionDef:variant", + "ui/BulkActionDef:visible", + "ui/BulkActionParam:default", + "ui/BulkActionParam:help", + "ui/BulkActionParam:label", + "ui/BulkActionParam:labelField", + "ui/BulkActionParam:multiple", + "ui/BulkActionParam:name", + "ui/BulkActionParam:object", + "ui/BulkActionParam:options", + "ui/BulkActionParam:placeholder", + "ui/BulkActionParam:required", + "ui/BulkActionParam:type", "ui/CalendarConfig:colorField", "ui/CalendarConfig:endDateField", "ui/CalendarConfig:startDateField", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json new file mode 100644 index 0000000000..7f8d8673de --- /dev/null +++ b/packages/spec/dual-source-exports.baseline.json @@ -0,0 +1,57 @@ +{ + "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", + "entries": [ + "ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]", + "ActivationEventSchema — [./kernel (const)] ≠ [./studio (const)]", + "AnalyticsQuery — [./contracts (interface)] ≠ [./data (type)]", + "CacheStrategy — [./shared (type)] ≠ [./system (type)]", + "ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]", + "ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]", + "DataSyncConfig — [./automation (type)] ≠ [./integration (type)]", + "DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]", + "DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]", + "EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]", + "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", + "EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]", + "EventSchema — [./automation (const)] ≠ [./kernel (const)]", + "FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]", + "FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]", + "HealthStatus — [./contracts (interface)] ≠ [./kernel (type)]", + "HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]", + "HttpRequest — [./shared (type)] ≠ [./ui (type)]", + "JobExecution — [./contracts (interface)] ≠ [./system (type)]", + "JobSchedule — [./contracts (interface)] ≠ [./system (type)]", + "MetadataBulkRegisterRequestSchema — [./api (const)] ≠ [./kernel (const)]", + "MetadataEvent — [./api (type)] ≠ [./kernel (type)]", + "MetadataEventSchema — [./api (const)] ≠ [./kernel (const)]", + "MetadataExportOptions — [./contracts (interface)] ≠ [./system (type)]", + "MetadataFormat — [./shared (type)] ≠ [./system (type)]", + "MetadataFormatSchema — [./shared (const)] ≠ [./system (const)]", + "MetadataImportOptions — [./contracts (interface)] ≠ [./system (type)]", + "Notification — [./api (type)] ≠ [./ui (type)]", + "NotificationChannel — [./contracts (type)] ≠ [./system (type)]", + "NotificationConfig — [./system (type)] ≠ [./ui (type)]", + "NotificationConfigSchema — [./system (const)] ≠ [./ui (const)]", + "NotificationSchema — [./api (const)] ≠ [./ui (const)]", + "PackageDependency — [./cloud (type)] ≠ [./kernel (type)]", + "PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]", + "PluginStartupResult — [./contracts (interface)] ≠ [./kernel (type)]", + "RateLimitConfig — [./integration (type)] ≠ [./shared (type)]", + "RateLimitConfigSchema — [./integration (const)] ≠ [./shared (const)]", + "RetryPolicy — [./automation (type)] ≠ [./system (type)]", + "RetryPolicySchema — [./automation (const)] ≠ [./system (const)]", + "Session — [./api (type)] ≠ [./identity (type)]", + "SessionSchema — [./api (const)] ≠ [./identity (const)]", + "ShareRecipientType — [./contracts (type)] ≠ [./security (const)]", + "StartupOptions — [./contracts (interface)] ≠ [./kernel (type)]", + "TenantPlan — [./cloud (type)] ≠ [./system (type)]", + "TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]", + "TransformType — [./data (const)] ≠ [./shared (type)]", + "ValidationResult — [./contracts (interface)] ≠ [./kernel (type)]", + "WebhookConfig — [./api (type)] ≠ [./integration (type)]", + "WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]", + "WebhookEvent — [./api (type)] ≠ [./integration (type)]", + "WebhookEventSchema — [./api (const)] ≠ [./integration (const)]", + "suggestFieldType — [., ./shared (function)] ≠ [./data (function)]" + ] +} diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 4022e4eb8d..afd74714db 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -272,10 +272,6 @@ "api/GetUiViewResponse", "api/GetViewRequest", "api/GetViewResponse", - "api/GetWorkflowConfigRequest", - "api/GetWorkflowConfigResponse", - "api/GetWorkflowStateRequest", - "api/GetWorkflowStateResponse", "api/HandlerStatus", "api/HttpFindQueryParams", "api/HttpMethod", @@ -499,9 +495,6 @@ "api/WebhookConfig", "api/WebhookEvent", "api/WellKnownCapabilities", - "api/WorkflowState", - "api/WorkflowTransitionRequest", - "api/WorkflowTransitionResponse", "automation/ActionCategory", "automation/ActionDescriptor", "automation/ActionParadigm", @@ -511,9 +504,6 @@ "automation/ApprovalNodeApprover", "automation/ApprovalNodeConfig", "automation/ApproverType", - "automation/AuthField", - "automation/Authentication", - "automation/AuthenticationType", "automation/BpmnDiagnostic", "automation/BpmnElementMapping", "automation/BpmnExportOptions", @@ -524,11 +514,6 @@ "automation/Checkpoint", "automation/ConcurrencyPolicy", "automation/ConflictResolution", - "automation/Connector", - "automation/ConnectorCategory", - "automation/ConnectorInstance", - "automation/ConnectorOperation", - "automation/ConnectorTrigger", "automation/CreateRecordConfig", "automation/DataDestinationConfig", "automation/DataSourceConfig", @@ -572,9 +557,6 @@ "automation/MapConfig", "automation/NodeExecutorDescriptor", "automation/NotifyConfig", - "automation/OAuth2Config", - "automation/OperationParameter", - "automation/OperationType", "automation/ParallelBranch", "automation/ParallelConfig", "automation/RetryPolicy", @@ -703,6 +685,7 @@ "data/AnalyticsQuery", "data/ApiMethod", "data/ApiOperation", + "data/AutoPersistenceConfig", "data/BaseEngineOptions", "data/CalendarDateValue", "data/ClockTimeValue", @@ -750,6 +733,7 @@ "data/DriverConfig", "data/DriverDefinition", "data/DriverOptions", + "data/DriverSslToggle", "data/DriverType", "data/DroppedFieldsEvent", "data/ESignatureConfig", @@ -775,6 +759,7 @@ "data/FieldReference", "data/FieldType", "data/FileLikeValue", + "data/FilePersistenceConfig", "data/FileReferenceIdValue", "data/FileValue", "data/FilterCondition", @@ -790,11 +775,14 @@ "data/JSONValidation", "data/Lifecycle", "data/LifecycleClass", + "data/LocalStoragePersistenceConfig", "data/LocationCoordinates", "data/LocationValue", "data/Mapping", "data/Metric", "data/ModeSchema", + "data/MongoConfig", + "data/MysqlConfig", "data/NoSQLDataTypeMapping", "data/NoSQLDatabaseType", "data/NoSQLDriverConfig", @@ -814,7 +802,9 @@ "data/ObjectOwnershipEnum", "data/ObjectRequiredPermissions", "data/PerOperationRequiredPermissions", + "data/PersistenceType", "data/PoolConfig", + "data/PostgresConfig", "data/Query", "data/QueryFilter", "data/ReferenceIdValue", @@ -839,6 +829,10 @@ "data/ShardingConfig", "data/SortNode", "data/SpecialOperator", + "data/SqlAutoMigrate", + "data/SqliteConfig", + "data/SqliteWasmConfig", + "data/SqliteWasmPersistMode", "data/StateMachineValidation", "data/StringOperator", "data/TenancyConfig", @@ -875,10 +869,6 @@ "identity/Session", "identity/User", "identity/VerificationToken", - "integration/AckMode", - "integration/ApiVersionConfig", - "integration/BuildConfig", - "integration/CdcConfig", "integration/CircuitBreakerConfig", "integration/ConflictResolution", "integration/Connector", @@ -894,59 +884,16 @@ "integration/ConnectorStatus", "integration/ConnectorTrigger", "integration/ConnectorType", - "integration/ConsumerConfig", "integration/DataSyncConfig", - "integration/DatabaseConnector", - "integration/DatabasePoolConfig", - "integration/DatabaseProvider", - "integration/DatabaseTable", "integration/DeclarativeConnectorEntry", - "integration/DeliveryGuarantee", - "integration/DeploymentConfig", - "integration/DlqConfig", - "integration/DomainConfig", - "integration/EdgeFunctionConfig", - "integration/EnvironmentVariables", "integration/ErrorMappingConfig", "integration/ErrorMappingRule", "integration/FieldMapping", - "integration/FileAccessPattern", - "integration/FileFilterConfig", - "integration/FileMetadataConfig", - "integration/FileStorageConnector", - "integration/FileStorageProvider", - "integration/FileVersioningConfig", - "integration/GitHubActionsWorkflow", - "integration/GitHubCommitConfig", - "integration/GitHubConnector", - "integration/GitHubIssueTracking", - "integration/GitHubProvider", - "integration/GitHubPullRequestConfig", - "integration/GitHubReleaseConfig", - "integration/GitHubRepository", - "integration/GitRepositoryConfig", "integration/HealthCheckConfig", - "integration/MessageFormat", - "integration/MessageQueueConnector", - "integration/MessageQueueProvider", - "integration/MultipartUploadConfig", - "integration/ProducerConfig", "integration/RateLimitConfig", "integration/RateLimitStrategy", "integration/RetryConfig", - "integration/SaasConnector", - "integration/SaasObjectType", - "integration/SaasProvider", - "integration/SslConfig", - "integration/StorageBucket", "integration/SyncStrategy", - "integration/TopicQueue", - "integration/VercelConnector", - "integration/VercelFramework", - "integration/VercelMonitoring", - "integration/VercelProject", - "integration/VercelProvider", - "integration/VercelTeam", "integration/WebhookConfig", "integration/WebhookEvent", "integration/WebhookSignatureAlgorithm", @@ -1025,17 +972,10 @@ "kernel/MetadataCategoryEnum", "kernel/MetadataChangeOperation", "kernel/MetadataChangeType", - "kernel/MetadataCollectionInfo", "kernel/MetadataDependency", "kernel/MetadataDiffItem", "kernel/MetadataEvent", - "kernel/MetadataExportOptions", "kernel/MetadataFallbackStrategy", - "kernel/MetadataFormat", - "kernel/MetadataImportOptions", - "kernel/MetadataLoadOptions", - "kernel/MetadataLoadResult", - "kernel/MetadataLoaderContract", "kernel/MetadataLock", "kernel/MetadataLockSource", "kernel/MetadataManagerConfig", @@ -1045,13 +985,9 @@ "kernel/MetadataProvenance", "kernel/MetadataQuery", "kernel/MetadataQueryResult", - "kernel/MetadataSaveOptions", - "kernel/MetadataSaveResult", - "kernel/MetadataStats", "kernel/MetadataType", "kernel/MetadataTypeRegistryEntry", "kernel/MetadataValidationResult", - "kernel/MetadataWatchEvent", "kernel/MultiVersionSupport", "kernel/NamespaceConflictError", "kernel/NamespaceRegistryEntry", @@ -1591,6 +1527,10 @@ "ui/BreakpointColumnMap", "ui/BreakpointName", "ui/BreakpointOrderMap", + "ui/BulkActionDef", + "ui/BulkActionExecution", + "ui/BulkActionOperation", + "ui/BulkActionParam", "ui/CalendarConfig", "ui/ChartAggregate", "ui/ChartAggregateFunction", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 5b048788c6..c7a751e0c8 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -463,49 +463,72 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 17 governed types - -Counts include drilled `children` entries; regenerate with the snippet below rather -than hand-editing (this table drifted badly once — field was listed 34/39 while the -ledger actually said 54/6). +## Current state — 27 governed types (complete registry coverage) + +**The counting method for this table is the gate's own report** — +`check-liveness.mts --json`, `types..byStatus` — decided in #4488 after +two methods spent a release disagreeing. The alternative (a python snippet that +counted ledger JSON rows) systematically undercounted: it missed statuses +resolved from `describe()` markers, the ADR-0010 framework overlay fields the +gate auto-classifies `live`, and `childrenDefault` fan-outs — and a mechanical +rewrite with it produced two regressions while #4487 was being written. The +gate's numbers are what CI actually enforces, so they are what the table +mirrors. Two corollaries: counts are at the gate's **one-level walk +granularity** (a Notes cell may annotate finer detail, e.g. `query`'s +marker-experimental search sub-keys, without the counts reflecting it), and the +count columns are **never hand-edited** — regenerate: ```bash -python3 - <<'EOF' -import json, glob, os -from collections import Counter -for f in sorted(glob.glob('packages/spec/liveness/*.json')): - d = json.load(open(f)); c = Counter() - def walk(ps): - for v in ps.values(): - if 'status' in v: c[v['status']] += 1 - walk(v.get('children') or {}) - walk(d.get('props', {})) - print(os.path.basename(f)[:-5], dict(c)) -EOF +cd packages/spec && npx tsx scripts/liveness/check-liveness.mts --json | python3 -c " +import json,sys +r = json.load(sys.stdin) +for t, v in r['types'].items(): + b = v['byStatus'] + print(f\"| {t} | {b.get('live',0)} | {b.get('experimental',0)} | {b.get('dead',0)} | {b.get('planned',0)} |\")" ``` | Type | live | exp | dead | planned | Notes | |---|---|---|---|---|---| -| object | 40 | – | 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | -| field | 55 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | -| flow | 26 | – | 5 | – | dead count = 4 tombstone entries + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges); remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | +| object | 49 | – | 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | +| field | 59 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | +| flow | 34 | – | 5 | – | dead count = 4 tombstone entries + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges); remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | | action | 34 | 0 | 2 | – | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | | hook | 11 | – | 2 | – | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | -| permission | 29 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | -| position | 4 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | -| agent | 13 | 4 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | -| tool | 5 | 1 | 0 | – | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | -| skill | 8 | – | 1 | – | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | -| dataset | 19 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | +| permission | 38 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | +| position | 12 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | +| agent | 21 | 4 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | +| tool | 13 | 1 | 0 | – | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | +| skill | 16 | – | 1 | – | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | +| dataset | 27 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | 16 | – | – | 1 | fully live + one planned | -| view | 70 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | - -| report | 13 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | -| dashboard | 10 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | -| query | 16 | 7 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The 7 experimental resolve from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | -| webhook | 0 | 1 | 16 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema` (#3461/#3462). The ENTIRE authoring surface is dead: nothing materializes an authored `webhooks:` entry into a `sys_webhook` dispatcher row (#3461, enforce-or-remove pending). `url` carries the single per-webhook `authorWarn` (one no-op heads-up per artifact, not per-prop); `authentication` experimental (HMAC-`secret`-only); `isActive` unmarked (default(true)). Notes cite the sys_webhook column map as the future materializer's mapping table | +| view | 79 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | + +| report | 21 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | +| dashboard | 18 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | +| query | 16 | 1 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | +| datasource | 23 | – | 20 | – | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) | +| webhook | 11 | 0 | 0 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | +| app | 45 | – | 14 | – | seeded 2026-08-01 (#4488). Dead 14 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (the landing IS the first nav item; root landing follows `isDefault` routing) + the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` (nothing evaluates them, while the per-ITEM siblings are enforced server- and client-side — the audit's most important app finding, both authorWarn'd) + `areas.order`/`description` + selector `includeAll` (deliberately ignored: selectors are mandatory-scope; an "All" would leak system metadata) and `placement`. Nav walk covers the union's `object` variant; other variants hand-verified live except the `actionDef` dispatch gap (renders, but no shipped shell passes `onAction`) — #4509 | +| book | 13 | – | 2 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | +| doc | 7 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type | +| email_template | 8 | – | 13 | – | seeded 2026-08-01 (#4488). **Every authorable property is dead** — the 8 live are the ADR-0010 framework overlay fields the gate auto-classifies. Webhook's OLD shape: `sendTemplate` reads `sys_email_template` ROWS, whose only writers are the built-in auth templates + code-constructed plugin options; every authoring door (stack `emailTemplates:`, `*.email-template.ts`, Studio metadata-admin, PUT /meta) lands items nothing reads back. An admin who "fixes" the password-reset mail in Studio changes nothing — false compliance on AUTH mail. One per-artifact authorWarn on `name`; `upsertTemplate`'s field map is the future bridge's mapping table — #4509 | +| job | 6 | – | 3 | – | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). Type-level gap recorded: `allowRuntimeCreate: true` but no path schedules a runtime-authored job item — #4509 | +| mapping | 7 | – | 3 | – | seeded 2026-08-01 (#4488). The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. Dead 3 = `extractQuery` (authorWarn — "for export only" promises an export path that does not exist) + `errorPolicy`/`batchSize` (dead but UNWARNABLE: their schema defaults materialize at compile, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule) | +| seed | 5 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | +| translation | 10 | – | 1 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | +| validation | 8 | – | 3 | – | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys (transitions/initialStates/regex/schema/when/then/…) verified via the evaluator's own tests. Type-level gap: a STANDALONE `validation` item binds to no object and reaches no write path — #4509 | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every -misleading entry carries `authorWarn` so authors hear about it at compile time. -Not yet governed (rollout): app, job, datasource, -translation, email_template, doc, book, validation, seed. +misleading entry carries `authorWarn` so authors hear about it at compile time +(governed types with warn entries must also be registered in the CLI lint's +`TYPE_COLLECTIONS` — see lint-liveness-properties.ts). + +**Coverage is complete as of #4488**: every type in the metadata-type registry +is governed, and `PENDING_GOVERNANCE` in `check-liveness.mts` is empty. The map +itself stays, because the ratchet is the point — registering a new type without +a ledger fails CI with instructions to govern it or record the debt (reason + +issue number). The paragraph that used to sit here, listing nine ungoverned +types as prose, is precisely how the gap survived for a year: prose cannot fail +a build. Now the gate compares `GOVERNED` against the registry in both +directions (an ungoverned registered type fails; so does a stale pending row +whose debt is already paid). diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json new file mode 100644 index 0000000000..81452f5fdc --- /dev/null +++ b/packages/spec/liveness/app.json @@ -0,0 +1,316 @@ +{ + "type": "app", + "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:1796-1847 — the SERVER-side authority for app/nav permission + capability gating and ADR-0045 hidden-app visibility), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. ONE GAP found there, recorded not hidden: an `action` item renders and gates like any other, but its click dispatches through a host-supplied `onAction` prop that NO shipped shell passes — `actionDef.actionName` currently reaches no dispatcher (#4509). Also note filterAppForUser strips only the TOP-LEVEL `navigation` tree; `areas` trees rely on the client-side per-item gates. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:478", + "note": "routing identity (`/apps/`) and the translation-bundle key (`apps..*`); objectui RootLandingRedirect routes by it." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:481; objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:327-355 (switcher/header)", + "note": "localized on serve by translateApp, rendered by the app switcher and shell header." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:333; framework packages/spec/src/system/i18n-resolver.ts:482", + "note": "rendered under the active app's title; localized by translateApp." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:327, :355", + "note": "App Launcher / switcher icon." + }, + "branding": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:185-186 (logo, primaryColor); objectui packages/app-shell/src/layout/ConsoleLayout.tsx:172-173 (accentColor, favicon)", + "note": "all four children live in the shell chrome; `accentColor` and the `separator`/`badgeVariant` nav keys were themselves inverse-drift fixes (declared to match an existing objectui read, liveness audit #1878/#1891/#1894)." + }, + "active": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178", + "note": "`active: false` delists the app from the switcher. Deliberately does NOT disable routing — the active-app lookup spans all apps so a direct /apps/ URL keeps rendering (AppSidebar:179-180 comment). Weaker than the name implies, but a real consumer." + }, + "isDefault": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/components/RootLandingRedirect.tsx:46", + "note": "ROUTING semantics: the root landing redirects to the app marked default (it was once a display-only badge — the file says so)." + }, + "hidden": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1811; objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178", + "note": "SERVER-enforced (ADR-0045): a hidden app is served only to builders (studio/setup access) for direct-URL preview; the client switcher filter is a listing courtesy on top." + }, + "navigation": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:125", + "note": "item identity: render keys, pin/reorder persistence, i18n nav key (`apps..navigation..label`)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:456; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:284-316 (resolveNavItemLabel)", + "note": "rendered everywhere; translateApp swaps in the per-locale label by node id." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:961", + "note": "every variant branch resolves and renders it." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:1154, :913", + "note": "low-first stable sort at the top level and inside each group." + }, + "badge": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:983-985" + }, + "badgeVariant": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:983, :1024", + "note": "declared to match this exact read (inverse-drift fix, audit #1878/#1891/#1894)." + }, + "visible": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:891 (item gate); objectui packages/app-shell/src/layout/AppSidebar.tsx:236 (CEL evaluation via ExpressionProvider)", + "note": "the CEL visibility gate — enforced per item. Note the contrast with `areas[].visible`, which is NOT." + }, + "requiredPermissions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1830; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:894", + "note": "enforced in BOTH layers: the server strips unsatisfied entries from the top-level navigation tree before serving, and the client re-gates per item." + }, + "requiresObject": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:899-901", + "note": "runtime-capability gate against the SchemaRegistry (client-side; the server gates only requiresService)." + }, + "requiresService": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1832; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:900-902", + "note": "ADR-0057 D10 capability gate, server + client." + }, + "type": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:905-960 (branch dispatch), :397-471 (href resolution per variant)", + "note": "the discriminant. Variant payload keys outside this walk are covered in the type note — all live except the `actionDef` dispatch gap." + }, + "objectName": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:296, :397-418" + }, + "viewName": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:294-296, :418", + "note": "target precedence recordId → filters → viewName; also keys the view-label i18n lookup." + }, + "recordId": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:397-406, :598", + "note": "direct-to-record deep link; {current_user_id}/{current_org_id} and context-selector {} template vars substituted by the shell." + }, + "recordMode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:406" + }, + "filters": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:416-418", + "note": "serialized as filter[]= params onto the bare /data surface (objectui ADR-0055); exclusivity with recordId/viewName is parse-rejected (objectNavTargetExclusivity)." + }, + "children": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:910-913", + "note": "recursive render with per-group order sort; the server's filterNav collapses groups emptied by permission stripping (rest-server.ts:1837)." + } + }, + "note": "Walked children are the `object` variant + base keys (union-first rule) — see the type note for the other variants' hand-verified payload keys." + }, + "areas": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:197-210", + "note": "area-switcher identity and active-area state key." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:456" + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:448" + }, + "order": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — no renderer sorts areas (AppSidebar and AppSchemaRenderer both iterate the array as authored), so declaration order is the display order. Reorder the `areas` array instead.", + "note": "Contrast with nav-item `order`, which IS sorted (NavigationRenderer.tsx:1154) — the sibling that works is what makes this one read alive." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "display annotation no surface renders. Benign — docs-shaped, kept, not warned (hook.label precedent)." + }, + "visible": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it, or gate the items INSIDE the area — nothing evaluates an area-level `visible` predicate, so a 'hidden' area renders for everyone: a capability gate that fails open, the worst shape of the silent no-op (#4001's own words). Per-ITEM `visible` IS enforced (NavigationRenderer.tsx:891).", + "note": "The schema declares it with the same CEL wording as the enforced item-level key, which is exactly what makes it a trap." + }, + "requiredPermissions": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it, or gate per item / per app — no layer checks area-level permissions (the server's filterAppForUser walks only the top-level `navigation` tree, and the client area switcher renders every area). Per-item `requiredPermissions` are enforced server + client, and app-level `requiredPermissions` are enforced server-side (rest-server.ts:1814).", + "note": "Fail-open access gate — same class as `visible` above; the two are this ledger's most important app findings." + }, + "navigation": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:210, packages/layout/src/AppSchemaRenderer.tsx:469", + "note": "the active area's tree replaces the top-level navigation. NOTE: area trees are NOT server-side permission-stripped (filterAppForUser reads only `item.navigation`) — per-item gating inside an area is client-side only." + } + }, + "note": "Drilled because the gating keys diverge sharply from the live identity/tree keys." + }, + "contextSelectors": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:199", + "note": "also the nav template-variable name ({} substitution into recordId/params)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:232" + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:231" + }, + "optionsSource": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:90-125", + "note": "endpoint fetched, valueKey/labelKey dotted-path mapped, `filter` predicates applied per row (rowPasses, :69-71)." + }, + "includeAll": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "default(true) boolean — the lint cannot tell author-set from schema default, so marking it would warn on every selector.", + "note": "DELIBERATELY ignored by the renderer (ContextSelectors.tsx:242-246): selectors are mandatory-scope — an 'All' row would unscope the surface and, for Studio's package filter, leak system metadata. The renderer never shows an All option regardless of this flag. Candidate for retiredKey removal (the renderer comment is the prescription)." + }, + "allValue": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:177, :199, :246", + "note": "READ, but only as the 'nothing concrete selected' sentinel for auto-selection and query-param defaulting — its documented purpose ('value emitted when All is selected') can never occur because includeAll is ignored (see above). Live by the letter, vestigial by intent; re-verify if includeAll is ever removed." + }, + "persist": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:164", + "note": "'none' opts out of persistence; query/session honored." + }, + "placement": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "enum with default('sidebar_header') — the default materializes at compile, so presence ≠ authored (same rule as errorPolicy on mapping).", + "note": "no renderer reads it — selectors always render in the sidebar header block (AppSidebar:469-472); 'topbar' places nothing in the topbar." + } + }, + "note": "Drilled because includeAll/placement diverge (dead) from the live core." + }, + "homePageId": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. No shell reads it — the landing IS the first navigation item (in `order`), and the ROOT landing follows `isDefault` routing (objectui RootLandingRedirect). Reorder `navigation` or set `isDefault` instead.", + "note": "The schema's own hedge ('if not set, usually defaults to the first navigation item') describes the only behavior that exists." + }, + "requiredPermissions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1814", + "note": "SERVER-enforced: an app whose required permissions are not a subset of the caller's system permissions is dropped from /meta entirely (and the single-item GET re-checks at rest-server.ts:3298)." + }, + "defaultAgent": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/hooks/surfaceAgent.ts:81, packages/app-shell/src/layout/ChatDock.tsx:253", + "note": "bounded surface-binding knob (ADR-0063): resolved to the two platform agents (ask/build), alias-aware, anything else rejected — exactly as the schema documents." + }, + "version": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, 2026-06 audit) — authoring it is parse-rejected with the prescription (an app is versioned by its package's manifest.version). Row stays while the tombstone keeps the key in the walked shape." + }, + "aria": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering component/widget." + }, + "objects": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — objects belong to the stack; the ambient chatbot derives an app's object list from its nav items (collectNavObjects), never from App.objects." + }, + "apis": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — declarative endpoints belong to the stack." + }, + "sharing": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, ADR-0049) — a declared-but-unenforced security surface; the live sharing path is FormView.sharing." + }, + "embed": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, ADR-0049) — no iframe route ever read it; embedding is per form view." + }, + "mobileNavigation": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — fully unimplemented; returns if/when a real mobile navigation ships." + } + } +} diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json new file mode 100644 index 0000000000..153280b1f5 --- /dev/null +++ b/packages/spec/liveness/book.json @@ -0,0 +1,103 @@ +{ + "type": "book", + "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (packages/rest/src/rest-server.ts:3078-3169) driving the spec's pure resolveBookTree/audienceAllows (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). 15 of 17 live; the two dead entries are both inline `translations` maps that LOOK like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but have no resolver anywhere. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3098", + "note": "tree-route identity; an unknown name is treated as a package id and resolved as the implicit per-package book (§6.4)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:303", + "note": "carried into the resolved tree; portal cards fall back to `name`." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:341 (buildBookCards)", + "note": "portal landing-card subtitle. The framework tree omits it; the portal reads it off the raw /meta/book item." + }, + "translations": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. No resolver reads a book's inline translations map — the tree endpoint and the portal render `label`/`description` verbatim, and the generic bundle translator covers view/action/object/app/dashboard/page only (i18n-resolver.ts METADATA_DOCUMENT_TRANSLATORS), not `book`. Locale-variant doc CONTENT belongs on `doc.translations`, which IS enforced (resolveDocLocale); book/group titles currently have no i18n mechanism at all.", + "note": "The trap is proximity: `doc.translations` two files over works on every read path, so this map reads as the same feature. It is parsed and stored and nothing ever looks at it." + }, + "slug": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:132 (bookSlug), :339", + "note": "portal URL segment (`/docs/`), defaulting to `name`. Portal-side consumer only — the framework tree endpoint routes by `name`." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:345 (buildBookCards)", + "note": "portal landing-card icon." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:302-312 (sortBooks)", + "note": "orders books on the portal landing (then label, stable). Authored books always sort ahead of synthetic per-package ones regardless of `order`." + }, + "audience": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3113, packages/rest/src/rest-server.ts:2969, packages/spec/src/system/book.zod.ts:351", + "note": "ENFORCED access gate (§6.7), fail-closed: gates the whole tree (401 anonymous / 403 non-holder), and every doc's effective audience is the union over the books that claim it (resolveDocAudiences) — applied to both doc lists and tree entries. The one security-shaped property on this type, and it is real." + }, + "groups": { + "children": { + "key": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:238, packages/spec/src/system/book.zod.ts:290", + "note": "group identity: explicit `doc.group` placement matches on it, and it keys the resolved tree." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:290", + "note": "section title in the resolved tree." + }, + "translations": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — see the book-level `translations` entry: no resolver reads inline book/group translations; group labels render verbatim in every locale.", + "note": "Same dead map one level down." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:221", + "note": "orders groups within the book (0 default, then declaration order)." + }, + "include": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:236, packages/spec/src/system/book.zod.ts:193", + "note": "the derived-membership rule — the heart of the §6.2.1 design. CAVEAT, recorded not hidden: only the GLOB form can match today. The `{ tag }` variant is declared and the resolver implements it (matchesInclude reads doc.tags), but DocSchema declares no `tags` property, so the corpus always carries `tags: undefined` and a tag rule matches nothing. Either add `doc.tags` or retire the variant (enforce-or-remove)." + }, + "package": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:232", + "note": "scopes the rule to a package id (cross-package books, ADR-0048)." + }, + "pages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:248-286", + "note": "explicit curated-order override; `---` separators and `...` rest-expansion both implemented, node label/badge/icon overrides carried into entries." + } + }, + "note": "Drilled because `translations` diverges (dead) from its six live siblings." + } + } +} diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json new file mode 100644 index 0000000000..3b962257d0 --- /dev/null +++ b/packages/spec/liveness/datasource.json @@ -0,0 +1,177 @@ +{ + "type": "datasource", + "_note": "DatasourceSchema. Consumers: @objectstack/service-datasource (DatasourceConnectionService.toSpec → DatasourceConnectionSpec → createDefaultDatasourceDriverFactory), @objectstack/objectql (engine.ts federation write gate), @objectstack/runtime (external-validation-plugin). Seeded 2026-08-01 (#4487) after #4465/#4481 found six inert keys BY HAND on a type no gate governed. Method: the authoritative boundary is what crosses into `ConnectableDatasource` (datasource-connection-service.ts:45-74) and `DatasourceConnectionSpec` (contracts/datasource-driver-factory.ts:25-59) — a block on neither reaches no driver. objectui's DatasourcePreview renders `pool`/`ssl`/`retryPolicy`/`healthCheck` as SideBlocks and is NOT counted as evidence for any entry (see README, 'An authoring/preview renderer is NOT a runtime consumer' — the #4481 precedent was exactly this). Framework provenance/lock fields auto-live.", + "props": { + "name": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:674", + "note": "registry key + the driver name the engine routes on (`driver.name` must equal it)." + }, + "label": { + "status": "live", + "note": "display metadata (Setup → Datasources list). No runtime consumer by design — ADR-0033 docs-shaped, deliberately kept, not authorWarn'd." + }, + "driver": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:334", + "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch." + }, + "config": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:129", + "note": "per-driver connection config. Validated against the driver contract since #4410 (data/driver/config-registry.zod.ts). NOTE the walk boundary: `config` is a `z.record`, so the gate cannot see inside it — the keys an author actually writes (`host`, `port`, `filename`) are governed by the per-driver zod schemas in data/driver/*.zod.ts, not by this ledger. That is a real gap in coverage, recorded here rather than left implicit." + }, + "pool": { + "children": { + "min": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:181", + "note": "knex pool floor. Live only since #4465 — the factory used to hardcode `{ min: 0, max: 5 }` over the carried value." + }, + "max": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:182", + "note": "knex pool ceiling; also mapped onto the Mongo client's maxPoolSize (#4465)." + }, + "idleTimeoutMillis": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:183", + "note": "passed through to knex verbatim." + }, + "connectionTimeoutMillis": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:184", + "note": "mapped onto knex's `acquireTimeoutMillis` (different name, same meaning)." + } + } + }, + "capabilities": { + "children": { + "transactions": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. The engine gates pushdown on the runtime driver's own `supports.*` object (objectql/src/engine.ts:3671, :4529, :4810 — `autonumber`, `queryDateGranularity`, `batchSchemaSync`), which is a different mechanism with a different vocabulary. Nothing reads this block." }, + "queryFilters": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Filter support is not negotiated through datasource metadata." }, + "queryAggregations": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Whether aggregation runs in SQL or in memory is decided by the driver's own code path, never by this flag." }, + "querySorting": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "queryPagination": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "queryWindowFunctions": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Window functions were themselves retired from the query surface in #4286." }, + "querySubqueries": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "joins": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. `query.joins` was retired in #4286; related-record retrieval is `expand`." }, + "fullTextSearch": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Search capability is deployment/locale-gated in the search companion, not declared here." }, + "readOnly": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. It does NOT make a datasource read-only — no write path consults it. The enforced datasource-wide write gate is `external.allowWrites: false` (objectql/src/engine.ts:620), which requires `schemaMode !== 'managed'`.", + "note": "The most dangerous entry in this ledger and the reason it was seeded. `readOnly` reads as a safety property, and until this PR TWO shipped prescriptions pointed authors AT it: the `externalSettingsUnknownKeyError` guidance in datasource.zod.ts and the #4465 changeset's relocation table both offered `capabilities.readOnly` as the place to 'describe the driver'. Both were corrected in #4487. An author following that advice believed they had marked a datasource non-writable and had not." + }, + "dynamicSchema": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Whether a driver is schemaless is a property of the driver, and the drivers that are (mongo, memory) behave that way unconditionally." } + }, + "note": "All 11 dead, verified 2026-08-01 by closing the call graph in both directions: (1) `capabilities` is absent from ConnectableDatasource AND DatasourceConnectionSpec, so it cannot reach a driver; (2) grepping each flag name across the monorepo returns only packages/spec — the schema's own declaration, its alias table, and the `*DriverSpec` literals in data/driver/*.zod.ts. The engine's real capability seam is `driver.supports?.*` on the runtime driver OBJECT, whose keys (`autonumber`, `batchSchemaSync`, `queryDateGranularity`) do not overlap with this vocabulary at all. `having-filter.ts:13` states the position plainly in a comment: 'SQL pushdown can come later behind a driver capability flag' — i.e. the mechanism this block describes is not built." + }, + "healthCheck": { + "children": { + "enabled": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. No health-check loop reads this block. Connection liveness is probed on demand via the driver handle's `ping()` / `checkHealth()` (contracts/datasource-driver-factory.ts:88-92), which the admin service calls for `testConnection` — not on any interval this could enable." }, + "intervalMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. Nothing schedules a datasource health check, so there is no interval to set. The only recurring datasource timer is `external.validation.checkIntervalMs` (schema-drift checking, a different concern)." }, + "timeoutMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. See `healthCheck.intervalMs` — there is no probe loop for this to bound." } + }, + "note": "All 3 dead, verified 2026-08-01. `healthCheck` is absent from ConnectableDatasource and DatasourceConnectionSpec. Every `healthCheck` hit in the monorepo belongs to a DIFFERENT surface — the PLUGIN health monitor (core/src/health-monitor.ts, core/src/plugin-loader.ts:316), the AI model registry, the integration connector, `StartupOrchestratorOptions.healthCheck`. Name collision, not a consumer. Easy to mis-verify: a bare grep for 'healthCheck' returns 20 hits and none of them is this block." + }, + "ssl": { + "children": { + "enabled": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:111", + "note": "`enabled: false` short-circuits to `ssl: false`; otherwise the block is assembled into client TLS options." + }, + "rejectUnauthorized": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:114" + }, + "ca": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:115" + }, + "cert": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:116" + }, + "key": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:117" + } + }, + "note": "Live only since #4465. Before that the whole block stopped at the record — nothing put it on the connection spec, so a TLS configuration with a CA certificate in it configured nothing while looking identical to one that worked. A security-shaped property that was silently inert; exactly the ADR-0078 class this ledger exists to catch, and it was found by hand rather than by a gate." + }, + "retryPolicy": { + "children": { + "maxRetries": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. No connect or query path retries on this block. Connection failure handling is the boot policy in datasource-connection-service.ts (degraded boot / `bootCritical` fail-fast), which does not retry on a schedule. Do not confuse this with `hook.retryPolicy` (enforced, objectql/src/hook-wrappers.ts:105) or `job.retryPolicy` (enforced, runtime/src/app-plugin.ts:791) — same key name, different types, different shapes." }, + "baseDelayMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`. Note this key does not even exist on the two retryPolicy blocks that ARE enforced: `hook.retryPolicy` spells its delay `backoffMs`." }, + "maxDelayMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`." }, + "backoffMultiplier": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`." } + }, + "note": "All 4 dead, verified 2026-08-01. Absent from ConnectableDatasource and DatasourceConnectionSpec. The trap here is the name: `retryPolicy` IS enforced on `hook` and on `job`, so a grep for the key looks alive and a reader who stops there concludes the datasource one works too. The shapes differ — hook uses `{maxRetries, backoffMs}`, this declares `{maxRetries, baseDelayMs, maxDelayMs, backoffMultiplier}` — which is itself the tell that nothing reads both." + }, + "description": { + "status": "live", + "note": "internal documentation. No runtime consumer by design — ADR-0033 docs-shaped, deliberately kept, not authorWarn'd." + }, + "active": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:296", + "note": "`active: false` skips the datasource in the boot auto-connect sweep, and datasource-admin-plugin.ts:424 excludes it from the runtime re-registration set. Genuinely enforced — unlike `flow.active` / `tool.active`, both of which were retired in v17 for claiming this and not delivering it." + }, + "autoConnect": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:233", + "note": "ADR-0062 D2(c): opts a managed, unrouted datasource into the boot connect sweep." + }, + "schemaMode": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:679", + "note": "carried onto the connection spec (#4410) and gates DDL at the driver; also read by objectql/src/engine.ts:620 for the federation write gate. Live only since #4465 — before that it was dropped between record and spec, so an `external` database ObjectStack must never run DDL against was constructed as `managed`. Security-shaped and silently inert; the second reason this ledger was seeded." + }, + "external": { + "children": { + "label": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. Nothing reads the federation block's own label — use the datasource's top-level `label`, which the Setup list renders." + }, + "allowedSchemas": { + "status": "live", + "evidence": "packages/services/service-datasource/src/external-datasource-service.ts:145", + "note": "restricts which remote schemas browse/introspect will surface (ADR-0015)." + }, + "allowWrites": { + "status": "live", + "evidence": "packages/objectql/src/engine.ts:620", + "note": "the enforced datasource-wide write gate (Gate 3). This — not `capabilities.readOnly` — is how a federated datasource is made read-only." + }, + "validation": { + "status": "live", + "evidence": "packages/runtime/src/external-validation-plugin.ts:153, packages/runtime/src/external-validation-plugin.ts:231", + "note": "`onMismatch` selects the drift policy (default 'fail'); `checkIntervalMs` schedules the recurring drift check; `checkOnBoot` gates the boot-time one. Also read by the degraded-boot classifier (packages/types/src/degraded-boot.ts:13)." + }, + "credentialsRef": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:457", + "note": "dereferenced through the SecretBinder to cleartext for the duration of one connect; never persisted or logged (ADR-0015 Addendum)." + }, + "queryTimeoutMs": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-admin-service.ts:220", + "note": "carried into the external-datasource probe options as `timeoutMs`." + }, + "requirePermission": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. No authorization check consults it — a permission named here gates nothing, and access to a federated datasource's data is governed by the ordinary object permission sets and RLS. Naming a permission that is never required is the same false-compliance shape as the retired `tool.permissions` (#3896)." + } + }, + "note": "5 of 7 live. The two dead ones are opposite in risk: `external.label` is cosmetic, `external.requirePermission` is security-shaped — it reads as an access gate and is not one." + }, + "origin": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-admin-plugin.ts:244", + "note": "server-stamped provenance (ADR-0015 Addendum). `code` marks a GitOps-owned datasource read-only in the UI; `runtime` marks one editable and re-registrable (datasource-admin-plugin.ts:424). Never accepted from client input." + } + } +} diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json new file mode 100644 index 0000000000..7227e2120d --- /dev/null +++ b/packages/spec/liveness/doc.json @@ -0,0 +1,48 @@ +{ + "type": "doc", + "_note": "DocSchema (ADR-0046 flat Markdown package docs). Fully live. The schema header calls docs 'inert data' — true of the KERNEL (it stores `content` unparsed), but every property has a real runtime consumer in the delivery layer: the REST read layer localizes, audience-gates and serves docs (packages/rest/src/rest-server.ts:2944-3022 list, :3384-3390 single item), and the `/meta/book/:name/tree` endpoint resolves book membership from doc headers via the spec's own resolveBookTree (packages/spec/src/system/book.zod.ts:218). objectui's console docs portal is a faithful port of the same resolver (apps/console/src/pages/book-nav.ts @940ba24) rendering the reader UI — a delivery surface for readers, NOT an authoring preview. Seeded 2026-08-01 (#4488). NOTE: DocSchema declares no `tags`, yet the book-side `include: { tag }` rule and the REST corpus (`d.tags`, rest-server.ts:2965) both expect one — the tag rule can currently never match; recorded on book.groups, not silently dropped.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:225, packages/rest/src/rest-server.ts:3129", + "note": "identity: the single-doc route key, the resolver's membership key (glob `include` matches over names), and the audience map key." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:198, packages/spec/src/system/book.zod.ts:202", + "note": "tree entry title + the order tiebreak sort key (byOrderThenLabel)." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:202, packages/rest/src/rest-server.ts:3131", + "note": "carried into tree entries and kept on the list response (which strips `content`) so portals can show summaries without fetching bodies." + }, + "content": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3007, packages/spec/src/system/doc.zod.ts:120", + "note": "the document body: served whole on single-doc GET, deliberately stripped from list responses unless `?include=content`, locale-swapped by resolveDocLocale." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:198", + "note": "sort key within a book group (0 when absent, then label)." + }, + "group": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:238", + "note": "explicit book-group membership; a doc joins the group whose `key` equals it when no `include` rule claims it first." + }, + "translations": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/doc.zod.ts:110, packages/rest/src/rest-server.ts:2996, packages/rest/src/rest-server.ts:3388", + "note": "per-locale {label,description,content} variants collapsed by resolveDocLocale on every read path (list, tree corpus, single item); the map itself is stripped from responses. This is the doc's OWN i18n mechanism — the generic bundle translator does not cover `doc`." + } + } +} diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json new file mode 100644 index 0000000000..fe8734bb53 --- /dev/null +++ b/packages/spec/liveness/email_template.json @@ -0,0 +1,73 @@ +{ + "type": "email_template", + "_note": "EmailTemplateDefinitionSchema. THE ENTIRE AUTHORING SURFACE IS DEAD — the webhook (#3461) disconnect shape, verified 2026-08-01 by closing the graph from both ends. Enforcement end: IEmailService.sendTemplate resolves (name, locale) against sys_email_template ROWS and honors active/variables/fromOverride/replyTo from the ROW (packages/plugins/plugin-email/src/email-service.ts:404-465). Writer end: the ONLY writers of sys_email_template are the built-in auth templates plus code-constructed EmailServicePluginOptions.templates, both via upsertTemplate (packages/plugins/plugin-email/src/email-plugin.ts:358-371, :431-468) — and no bootstrapper passes `templates` (the CLI serve composition omits it, packages/cli/src/commands/serve.ts:2165). Authoring end: every door an author can use — stack `emailTemplates:` (ingested as metadata items, metadata/src/plugin.ts:89), `*.email-template.ts` files, Studio (whose nav points at the metadata-admin list, platform-objects/src/apps/studio.app.ts:331), PUT /meta — lands items in the metadata store that NOTHING reads back; the package importer even excludes emailTemplates explicitly (packages/runtime/src/domains/packages.ts:569-571). So an admin who 'fixes' the password-reset email in Studio sees it saved and users keep receiving the builtin — ADR-0078 false compliance on AUTH mail. Enforce-or-remove tracked in #4509; upsertTemplate's field mapping (email-plugin.ts:432-449) is the future materializer's mapping table, exactly as the sys_webhook column map was for webhooks (#3489 closed that one). Per the webhook precedent, ONE per-artifact authorWarn is carried on `name` rather than one per property. `protection`/_lock*/_provenance are framework overlay fields, auto-live.", + "props": { + "name": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Authoring an `email_template` metadata item does NOT register it with the mail service: sendTemplate reads sys_email_template rows, and nothing materializes metadata items into that table (see the type note). Until the bridge exists, outbound-mail templates are the built-in auth set plus code-supplied EmailServicePluginOptions.templates — a template authored here saves cleanly and is never used.", + "note": "Would-be row mapping: `name` (the sendTemplate lookup key, email-service.ts:404)." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `label` (email-plugin.ts:434). Warn carried on `name` — one heads-up per artifact, not per prop." + }, + "category": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `category` (email-plugin.ts:435); a Studio filter facet even on the live rows, never behavior." + }, + "locale": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `locale` (email-plugin.ts:436) — on live rows this IS enforced ((name, locale) resolution with en-US fallback, email-service.ts:410-416), which is what makes the inert metadata copy so misleading." + }, + "subject": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `subject` (email-plugin.ts:437); rendered with {{path}} holes by renderTemplate on live rows (email-service.ts:444)." + }, + "bodyHtml": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `body_html` (email-plugin.ts:438)." + }, + "bodyText": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `body_text` (email-plugin.ts:439); live rows auto-derive text from HTML when absent (htmlToText)." + }, + "variables": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `variables_json` (email-plugin.ts:448); on live rows `required` variables fail sends fast (requireVars, email-service.ts:427-431). All four child keys share this verdict — not drilled." + }, + "fromOverride": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `from_address`/`from_name` (email-plugin.ts:440-443). Both child keys share this verdict." + }, + "replyTo": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `reply_to` (email-plugin.ts:444); honored on live rows (email-service.ts:463-464)." + }, + "active": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `active` (email-plugin.ts:445); on live rows `active: false` makes sendTemplate return TEMPLATE_INACTIVE (email-service.ts:418). default(true) boolean — could not carry authorWarn even if we wanted one (the lint cannot tell author-set from schema default)." + }, + "isSystem": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `is_system` (email-plugin.ts:446); on live rows it gates re-seeding (a tenant-customised row is never overwritten, email-plugin.ts:459)." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `description` (email-plugin.ts:447); docs-shaped even on live rows." + } + } +} diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json new file mode 100644 index 0000000000..36375f97de --- /dev/null +++ b/packages/spec/liveness/job.json @@ -0,0 +1,59 @@ +{ + "type": "job", + "_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:766-802) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP, recorded not hidden: `job` is registered `allowRuntimeCreate: true` (metadata-plugin.zod.ts:640) but ONLY the compiled bundle's `jobs` reach the scheduler — no code path schedules a runtime-authored `job` metadata item (a Studio-created job saves cleanly and never runs; its `handler` could not even resolve, since the function map lives in the bundle). Same disconnect class as webhook (#3461) — tracked in #4509. Seeded 2026-08-01.", + "props": { + "id": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:784), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.", + "note": "The describe() text ('defaults to `name` when omitted') implies an identity override that does not exist." + }, + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:767, packages/runtime/src/app-plugin.ts:784", + "note": "scheduling identity; a job without one is skipped loudly." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "display metadata; no runtime consumer (sys_job stores name/schedule only). Docs-shaped annotation, deliberately KEPT and not authorWarn'd — the hook.label/description precedent, exempt from enforce-or-remove (ADR-0033)." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "same as `label`: docs-shaped, deliberately kept, no warning." + }, + "schedule": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:786, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83", + "note": "all three variants enforced: cron `expression` + per-job `timezone` (cron-job-adapter.ts:76-77), interval `intervalMs` (:82), once `at` (:87); the db adapter persists the shape onto sys_job (db-job-adapter.ts:233-245). WALK BOUNDARY: a discriminated union — the gate classifies it as one property; the per-variant keys are covered by the adapter evidence above, not by ledger rows." + }, + "handler": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:776", + "note": "resolved against the bundle's function map; a missing handler skips the job with a warning rather than scheduling a no-op." + }, + "retryPolicy": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:791, packages/services/service-job/src/run-with-policy.ts:58-65", + "note": "maxRetries/backoffMs/backoffMultiplier all drive the exponential-backoff retry loop (delay = backoffMs * multiplier^(retry-1)). Enforced since #3494. This is the `retryPolicy` the datasource ledger warns about confusing with its dead namesake." + }, + "timeout": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/services/service-job/src/run-with-policy.ts:25-33", + "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented." + }, + "enabled": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:772", + "note": "`enabled: false` skips scheduling entirely at registration — genuinely enforced, unlike the retired flow.active/tool.active." + } + } +} diff --git a/packages/spec/liveness/mapping.json b/packages/spec/liveness/mapping.json new file mode 100644 index 0000000000..ff7c1202ba --- /dev/null +++ b/packages/spec/liveness/mapping.json @@ -0,0 +1,67 @@ +{ + "type": "mapping", + "_note": "MappingSchema (#2611 reusable import mapping). Consumers: the REST import path — resolveNamedMapping fetches the artifact by name and validates it against the request (packages/rest/src/import-mapping.ts:60-107), applyMappingToRows runs the fieldMapping pipeline (:115-167), and import-prepare adopts the artifact's mode/upsertKey as request defaults (packages/rest/src/import-prepare.ts:321-326); objectui's ImportWizard offers registered mappings in a saved-mapping picker (@940ba24 packages/plugin-grid/src/ImportWizard.tsx:979). 8 of 11 live. The IMPORT half of the schema is real and loudly enforced (unsupported transforms/formats are 400s, not silent skips — Prime Directive #10); the EXPORT half (`extractQuery`) and the tuning knobs (`errorPolicy`, `batchSize`) have no consumer anywhere. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:70", + "note": "artifact resolution key for the request's `mappingName` (missing → 404 MAPPING_NOT_FOUND)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/plugin-grid/src/ImportWizard.tsx:979", + "note": "saved-mapping picker option text (`label || name`) and the applied-mapping hint. Display metadata with a real selection surface — the datasource.label treatment, not authorWarn'd." + }, + "sourceFormat": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:81-97", + "note": "declared-format gate: `xml`/`sql` are rejected outright (the import endpoint accepts csv/json/xlsx), and a csv-declared mapping applies to xlsx too; a mismatch is a 400, never a silent reinterpretation." + }, + "targetObject": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:75", + "note": "must equal the URL object or the import 400s (MAPPING_TARGET_MISMATCH)." + }, + "fieldMapping": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:98-105, packages/rest/src/import-mapping.ts:115-167", + "note": "the pipeline itself: source/target/transform/params all consumed. none/constant/map/split/join applied in applyMappingToRows (`params.separator` :124, `.value` :132, `.valueMap` :137); `lookup` copies through for the pipeline's metaMap reference resolution; `javascript` is REJECTED with a 400 (no server sandbox — implement-or-reject-loudly). SUB-WALK BOUNDARY, recorded not hidden: `params`' lookup-specific keys (`object`/`fromField`/`toField`/`autoCreate`) are read by nothing — reference resolution comes from the target object's own field definitions, not from these — and they sit one level below the drill, so only this note governs them." + }, + "mode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-prepare.ts:322", + "note": "an artifact declaring update/upsert sets the import's writeMode default (an explicit request `writeMode` still wins)." + }, + "upsertKey": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-prepare.ts:325", + "note": "adopted as the upsert match fields when the request names none." + }, + "extractQuery": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. 'Query to run for export only' promises an export path that does not exist — no exporter reads any mapping artifact. Exports run through the ordinary query API; when a mapping-driven export lands, this is where it plugs in, but authoring it today configures nothing.", + "note": "A whole QuerySchema subtree hangs off this optional key; the single flat verdict covers all of it (no consumer reaches any child)." + }, + "errorPolicy": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "schema default('skip') materializes at compile (defineMapping parses), so on the compiled stack the lint cannot tell an authored value from the default — a warn here would fire on every mapping artifact. Same reason default(true) booleans are never marked.", + "note": "No import code reads it: error handling on the import path is the request's own options, and 'retry'/'abort' configure nothing. Dead but unwarnable — see _authorWarnSkipped." + }, + "batchSize": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "schema default(1000) materializes at compile — same unwarnable shape as errorPolicy.", + "note": "No import code batches by it; the write path sizes its own batches." + } + } +} diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json new file mode 100644 index 0000000000..9e24dfc113 --- /dev/null +++ b/packages/spec/liveness/seed.json @@ -0,0 +1,36 @@ +{ + "type": "seed", + "_note": "SeedSchema. Fully live — the smallest and healthiest surface in the ledger. Consumer: SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts), reached on BOTH authoring paths: (1) boot/replay — the stack's `data:` collection lands in `manifest.data`, app-plugin.ts normalizes it and calls seedLoader.load() (packages/runtime/src/app-plugin.ts:832, :971), plus the per-org replayer registered for tenant provisioning; (2) runtime drafts — publishMetaItem applies a published `seed` draft through the same loader (packages/metadata-protocol/src/protocol.ts:6764, `skipSeedApply` opt-out for package batches). Seeded 2026-08-01 (#4488).", + "props": { + "object": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:98", + "note": "target object; also the dependency-graph node key (topological insert order)." + }, + "externalId": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:119", + "note": "upsert/uniqueness key, single or composite (framework#3434 join tables); also what OTHER datasets' reference values resolve against (buildReferenceMap threads it into the DB probe)." + }, + "mode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:245", + "note": "insert/update/upsert/replace/ignore — drives decideWriteAction/writeRecord." + }, + "env": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:91", + "note": "filterByEnv drops datasets whose env list excludes the running environment." + }, + "records": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:434", + "note": "the payload rows. WALK BOUNDARY: each record is a z.record — the keys an author actually writes are the TARGET OBJECT's field names, governed by that object's own field definitions (and the defineSeed factory's compile-time key check), not by this ledger. Recorded here rather than left implicit, per the datasource `config` precedent." + } + } +} diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json new file mode 100644 index 0000000000..772980651b --- /dev/null +++ b/packages/spec/liveness/translation.json @@ -0,0 +1,73 @@ +{ + "type": "translation", + "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). Registered schema is a z.preprocess pipe (the retired object-first-dialect guard), which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through — the resolvers read only the declared conventions. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).", + "props": { + "locale": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/core/src/fallbacks/authored-translation-sync.ts:140-148", + "note": "which bundle entry the item fills. Required for a reason the schema states: the sync SKIPS an item whose locale it cannot resolve — loudly (warn log), with a name-derived fallback for pre-#3778 rows." + }, + "objects": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:735, :751, :159, :197, :873, :900; objectui @940ba24: packages/i18n/src/useObjectLabel.ts:397-400", + "note": "the largest group, fully live: label/pluralLabel/description (translateObject), fields.{label,help,placeholder,options}, _views (resolveViewLabel + empty-state copy), _actions (label/confirmText/successMessage/params/resultDialog — object-scoped first, then globalActions fallback), _sections (objectui record:details section labels). Served through REST translateMetaItem(s) and the /api/v1/i18n endpoints; objectui re-resolves client-side via the spec-translations transform." + }, + "apps": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:442, :456; packages/rest/src/rest-server.ts:2001", + "note": "translateApp swaps app label/description and walks the navigation tree replacing node labels by id — applied on every /meta app read." + }, + "messages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts:562-579, :744-745", + "note": "consumed via II18nService.t: plugin-audit localizes activity-feed summaries (messages.activityCreated/Updated/Deleted, framework#3039) and collaboration notifications (messages.mentionedYou). Easy to mis-verify — no resolver in i18n-resolver.ts reads it; the consumer is a t() caller with composed keys, which a literal grep for the group name never finds." + }, + "validationMessages": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — nothing resolves `validationMessages.` in either repo. A validation violation renders the rule's own authored `message` verbatim (rule-validator.ts), and the #3957 message-translation hook covers only the BUILT-IN field messages via the i18n service's `validation.field.*` keys. There is currently no per-locale override mechanism for rule messages; until one ships, translate by authoring per-locale rules or keep messages locale-neutral.", + "note": "The trap has the platform's own signature on it twice: the schema example shows a concrete override ({\"discount_limit\": \"折扣不能超过40%\"}), and #3778's legacy-key migration table steers retired `errors:` authors here ('use validationMessages for rule messages'). Both point at a group with no reader — the capabilities.readOnly shape. objectui's spec-translations transform passes the group through to the client tree, but no client code looks anything up under it (a passthrough is not a consumer)." + }, + "globalActions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:200, :249", + "note": "the object-less fallback for action label/confirmText/successMessage/params/resultDialog — resolveAction* checks objects.._actions first, then here." + }, + "dashboards": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:538, :554", + "note": "translateDashboard: label/description plus per-widget title/description by widget id; header action labels." + }, + "pages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:636", + "note": "translatePage: label/description/title/subtitle (title falls back to label; header copy keyed by page name because page:header instances carry no stable id)." + }, + "settings": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/settings/useSettingsLabel.ts:78", + "note": "the Settings UI resolves `.settings..{title,description,groups.*,keys.*,actions.*}` against the served tree — title/group/field/option/action labels all honored." + }, + "metadataForms": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:2058, :2062", + "note": "translateMetaTypes decorates GET /meta types with resolveMetadataTypeLabel and localizes every form schema through resolveMetadataFormLabels (labels/sections/fields by dotted path) — the Studio metadata-editor localization path." + }, + "settingsCommon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/settings/useSettingsLabel.ts:104", + "note": "cross-namespace Settings chrome strings (source badges by resolution layer); the client scans every namespace carrying a settingsCommon block." + } + } +} diff --git a/packages/spec/liveness/validation.json b/packages/spec/liveness/validation.json new file mode 100644 index 0000000000..ff5707b73e --- /dev/null +++ b/packages/spec/liveness/validation.json @@ -0,0 +1,69 @@ +{ + "type": "validation", + "_note": "ValidationRuleSchema — the ADR-0020 carrier, where a wrong verdict is expensive, so the call graph was closed with extra care. The walked shape is the discriminated union's FIRST object member (the base keys + `script`'s type/condition — the #3095 union rule); per-variant keys (state_machine's field/transitions/initialStates, format's regex/format, json_schema's schema, conditional's when/then/otherwise, cross_field's fields) sit OUTSIDE the walk — an explicit blind spot recorded here (the union analog of the z.record rule), governed by the evaluator's own tests, not ledger rows. Consumer: the engine write path calls evaluateValidationRules on insert and on every matched update row (packages/objectql/src/engine.ts:3703, :4017, :4085) with rules from the OBJECT's embedded `validations` array (+ object_extension merge, engine.ts:1559). The evaluator provably honors every execution-control key — the zod header's prose claiming it 'only reads type/condition/field/events/severity/message' is STALE (it predates enforcement of active/priority) and should not be trusted over the ledger. TYPE-LEVEL GAP, recorded not hidden: a STANDALONE `validation` metadata item (file `*.validation.ts` or Studio — allowRuntimeCreate: true, metadata-plugin.zod.ts:602) never reaches any object's write path — the schema has no object-binding key, no merge code exists, and only the reference-tracker even expects one (metadata-protocol/src/protocol.ts:1306). A state machine authored through that door saves cleanly and gates nothing. The per-prop verdicts below are for rules where rules actually live (`object.validations` — the same schema instance); the standalone-door disconnect is tracked in #4509. Seeded 2026-08-01.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:665, packages/objectql/src/validation/rule-validator.ts:676", + "note": "names the rule in violation logs and the broken-rule skip warning." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "governance/editor metadata, declared deliberately (the schema header says so): surfaced in rule listings, never evaluated on the write path. Docs-shaped, KEPT, not authorWarn'd — the hook.label precedent." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "same as `label` — governance annotation, deliberately kept." + }, + "active": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:647", + "note": "`active: false` filters the rule out before evaluation — genuinely enforced, unlike the retired flow.active/tool.active (worth stating on a validation surface: an rls.enabled-shaped failure here would be a data-integrity hole)." + }, + "events": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:654", + "note": "insert/update dispatch (default both). `delete` was removed from the enum in #3184 after being proven a silent no-op — guard deletions with a beforeDelete hook." + }, + "priority": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:657", + "note": "stable low-number-first sort of the evaluation order." + }, + "tags": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "categorization for reporting/management — governance metadata like label/description, deliberately kept." + }, + "severity": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:671-678", + "note": "only 'error' blocks the write; 'warning'/'info' violations are logged and let the write proceed." + }, + "message": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:676, packages/objectql/src/engine.ts:3703", + "note": "the author-written violation text carried on every FieldValidationError (surfaced as 400 VALIDATION_FAILED); per-deployment overrides resolve via validationMessages in the translation bundle (#3957) without touching the authored value." + }, + "type": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:692-706", + "note": "the union discriminant: dispatches to the state_machine/predicate/format/json_schema/conditional checkers; the schema admits exactly the handled set." + }, + "condition": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:697", + "note": "the CEL predicate (script/cross_field variants), evaluated against the merged record + previous — TRUE fails the write." + } + } +} diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 9f32c2b11f..6dc75b8766 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -135,7 +135,8 @@ }, "bulkActionDefs": { "status": "live", - "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Post-audit key, verified objectui@fb35e48." + "evidence": "packages/spec/src/ui/bulk-action.zod.ts", + "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Dispatch: useBulkExecutor.ts run() — per-record fan-out by default; execution:'aggregate' defs go through the ONE-call bulkCall branch injecting params._selectedIds (ObjectGrid.runBulkActionAggregate, objectui#3139). Verified objectui@4bf612c. #4457 gave the def a SHAPE (it was z.record(z.any())): the entry schema is BulkActionDefSchema, strict, and it refuses the combinations that parse but the executor never reads — so this row's liveness now covers the keys inside a def, not just the array." }, "virtualScroll": { "status": "live", diff --git a/packages/spec/package.json b/packages/spec/package.json index e432888031..0c2e663482 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -199,6 +199,7 @@ "gen:api-surface": "tsx scripts/build-api-surface.ts", "check:api-surface": "tsx scripts/build-api-surface.ts --check", "check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts", + "check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:spec-changes": "tsx scripts/build-spec-changes.ts", "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", @@ -213,7 +214,7 @@ "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", "check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check", - "check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts", + "check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts", "check:skill-examples": "tsx scripts/check-skill-examples.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/spec/react-conformance.baseline.json b/packages/spec/react-conformance.baseline.json deleted file mode 100644 index 7d1715f7bc..0000000000 --- a/packages/spec/react-conformance.baseline.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "_comment": "Accepted spec↔frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=… check:react-conformance --baseline --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.", - "blocks": { - "ObjectForm": { - "frontendOnly": [], - "missing": false - }, - "ListView": { - "frontendOnly": [], - "missing": false - }, - "ObjectChart": { - "frontendOnly": [], - "missing": false - }, - "RecordDetails": { - "frontendOnly": [], - "missing": false - }, - "RecordHighlights": { - "frontendOnly": [], - "missing": false - }, - "RecordRelatedList": { - "frontendOnly": [], - "missing": false - }, - "RecordPath": { - "frontendOnly": [], - "missing": false - } - } -} diff --git a/packages/spec/react-declaration-parity.baseline.json b/packages/spec/react-declaration-parity.baseline.json new file mode 100644 index 0000000000..51a6586aee --- /dev/null +++ b/packages/spec/react-declaration-parity.baseline.json @@ -0,0 +1,17 @@ +{ + "_comment": "Accepted spec↔registry DECLARATION-PARITY baseline (react blocks). Per block: the registry-only input set (the registry config declares it, the spec does not) and whether the block is missing. Regenerate with: MANIFEST=… check:react-declaration-parity --baseline --update. The ratchet flags only NEW registry-only inputs or newly-missing blocks. It compares two declarations and inspects no renderer, so a prop both sides declare and nothing reads records as agreement here (#4413/#4472).", + "blocks": { + "ObjectForm": { + "registryOnly": [], + "missing": false + }, + "ListView": { + "registryOnly": [], + "missing": false + }, + "ObjectChart": { + "registryOnly": [], + "missing": false + } + } +} diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index ba8c70b401..033b7097da 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -61,6 +61,41 @@ const schemaZodFileMap = new Map(); const categoryZodFiles = new Map>(); // Track Zod File collisions const zodFileCounts = new Map(); +/** + * Page slug -> its real path under `packages/spec/src//`. + * + * A page named after a NESTED file (`driver-postgres`) does not sit at + * `/driver-postgres.zod.ts`, so the "Source:" line has to be looked up + * rather than reassembled from the slug. Naming a file that does not exist is + * the same defect as a schema that does not validate: a reader following it + * finds nothing and has no way to tell the pointer was invented. + */ +const zodFileSourceRel = new Map(); + +/** + * `.zod.ts` files under a category, RECURSIVELY, keyed by the slug their page + * takes (`driver/postgres.zod.ts` → `driver-postgres`). + * + * The walk used to be one level deep, which made every schema under + * `data/driver/` invisible: those twelve landed in the catch-all `misc` bucket + * the moment they were exported (#4410), on a page whose "Source" line named + * `data/misc.zod.ts` — a file that does not exist. Same one-level-deep bug the + * strictness ledger's own coverage gate had, and the same lesson: a generator + * that under-reports produces confident output about surface it never saw. + */ +function collectZodFiles(dir: string, prefix = ''): Array<{ slug: string; rel: string }> { + const out: Array<{ slug: string; rel: string }> = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + out.push(...collectZodFiles(path.join(dir, entry.name), `${prefix}${entry.name}/`)); + continue; + } + if (!entry.name.endsWith('.zod.ts')) continue; + const rel = `${prefix}${entry.name}`; + out.push({ slug: rel.replace(/\.zod\.ts$/, '').replace(/\//g, '-'), rel }); + } + return out; +} // Scan source files to build maps function scanCategories() { @@ -69,33 +104,42 @@ function scanCategories() { if (!fs.existsSync(dir)) return; const zodFiles = new Set(); - const files = fs.readdirSync(dir).filter(f => f.endsWith('.zod.ts')); - - for (const file of files) { - const zodFileName = file.replace('.zod.ts', ''); - zodFiles.add(zodFileName); - - const count = zodFileCounts.get(zodFileName) || 0; - zodFileCounts.set(zodFileName, count + 1); - - const content = fs.readFileSync(path.join(dir, file), 'utf-8'); - + + for (const { slug, rel } of collectZodFiles(dir)) { + zodFiles.add(slug); + zodFileSourceRel.set(`${category}/${slug}`, rel); + + const count = zodFileCounts.get(slug) || 0; + zodFileCounts.set(slug, count + 1); + + const content = fs.readFileSync(path.join(dir, rel), 'utf-8'); + // Match export const Name = ... OR export const Name: Type = ... const regex = /export const (\w+)\s*(?:[:=])/g; - + let match; while ((match = regex.exec(content)) !== null) { const rawName = match[1]; const finalName = rawName.endsWith('Schema') ? rawName.replace('Schema', '') : rawName; schemaCategoryMap.set(finalName, category); - schemaZodFileMap.set(finalName, zodFileName); + schemaZodFileMap.set(finalName, slug); } } - + categoryZodFiles.set(category, zodFiles); }); } +/** + * Repo-relative source path for a page slug, or `undefined` when the slug has + * no file behind it (the `misc` catch-all bucket). Callers must omit the + * "Source:" pointer in that case rather than print a plausible-looking path. + */ +function sourcePathFor(category: string, zodFile: string): string | undefined { + const rel = zodFileSourceRel.get(`${category}/${zodFile}`); + return rel ? `packages/spec/src/${category}/${rel}` : undefined; +} + scanCategories(); /** @@ -387,9 +431,10 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, const zodTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); // Get source description - const sourcePath = path.join(SRC_DIR, category, `${zodFile}.zod.ts`); + const sourceRel = sourcePathFor(category, zodFile); + const sourcePath = sourceRel ? path.join(REPO_ROOT, sourceRel) : undefined; let fileDesc = ''; - if (fs.existsSync(sourcePath)) { + if (sourcePath && fs.existsSync(sourcePath)) { fileDesc = getFileDescription(fs.readFileSync(sourcePath, 'utf-8')); } @@ -403,9 +448,13 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, md += `${fileDesc}\n\n`; } - md += `\n`; - md += `**Source:** \`packages/spec/src/${category}/${zodFile}.zod.ts\`\n`; - md += `\n\n`; + // Only when there IS one — the `misc` catch-all has no file behind it, and a + // reassembled `packages/spec/src//misc.zod.ts` points at nothing. + if (sourceRel) { + md += `\n`; + md += `**Source:** \`${sourceRel}\`\n`; + md += `\n\n`; + } // Add TypeScript usage example const schemaNames = schemas.map(s => s.name).join(', '); @@ -457,7 +506,7 @@ const SECTION_GROUPS: Record { section: 'Service APIs', pages: ['core-services', 'auth', 'auth-endpoints', 'identity', 'metadata', 'metadata-plugin', 'automation-api', 'analytics', 'export', 'storage', 'notification', 'events', 'connector', 'package-api', 'package-registry', 'plugin-rest-api'] }, ], automation: [ - { section: 'Flow & Execution', pages: ['flow', 'control-flow', 'execution', 'node-executor', 'state-machine', 'trigger-registry', 'time-relative-trigger'] }, + { section: 'Flow & Execution', pages: ['flow', 'control-flow', 'execution', 'node-executor', 'state-machine', 'time-relative-trigger'] }, { section: 'Integration & Data', pages: ['sync', 'etl', 'connector', 'webhook', 'bpmn-interop', 'offline'] }, { section: 'Approvals & Jobs', pages: ['approval', 'job'] }, ], @@ -634,8 +683,9 @@ Object.entries(CATEGORIES).forEach(([category, title]) => { // written and the stale files are still lying around.) if (!wasEmitted(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return; const fileTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + const cardSource = sourcePathFor(category, zodFile); // Link relative to the category folder (where index.mdx lives) - mdx += ` \n`; + mdx += ` \n`; }); mdx += `\n`; diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index ba78360474..429ca9a9f1 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -79,7 +79,6 @@ const SKILL_MAP: Record = { ], 'objectstack-automation': [ 'automation/flow.zod.ts', - 'automation/trigger-registry.zod.ts', 'automation/time-relative-trigger.zod.ts', 'automation/approval.zod.ts', 'automation/state-machine.zod.ts', diff --git a/packages/spec/scripts/check-dual-source-exports.ts b/packages/spec/scripts/check-dual-source-exports.ts new file mode 100644 index 0000000000..251b288c4d --- /dev/null +++ b/packages/spec/scripts/check-dual-source-exports.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-dual-source-exports.ts — no two entry points of @objectstack/spec may + * export the same name for DIFFERENT declarations. + * + * `api-surface.json` records every `name (kind)` per entry point, so a name + * appearing on two entries is VISIBLE there — but nothing distinguishes the two + * ways that can happen, and only one of them is fine: + * + * - re-export: both entries resolve to the SAME declaration. One symbol, + * two import paths. Harmless, common (root `.` re-exports the domains). + * - dual-source: each entry resolves to its OWN declaration under a shared + * name. Which type you get depends on nothing but the import path. + * + * The dual-source case is the #4411 trap. Spec carried two differently-shaped + * `MetadataWatchEvent`s on `./kernel` and `./system` — plus ten more pairs in + * the same file — and the naming intuition pointed the WRONG way: the copy that + * looked canonical (normalized enums, required fields, a `.describe()` per + * property) was the dead one. An auto-import or a model completion picking by + * name, or by which copy reads as more rigorous, picked the dead one; because + * the shapes overlapped heavily, the wrong pick compiled and failed later, at + * an edge value (`add` vs `added`) or on a field one copy made required. No + * human review catches this: each file is locally reasonable. + * + * So the distinction is drawn where it exists — SYMBOL IDENTITY, not name. + * Every export of every public entry is resolved through its alias chain to + * the original symbol; a name whose entries resolve to two or more distinct + * symbols is dual-source. Judging by name alone would drown the signal in + * ~80 legitimate re-exports. + * + * The existing dual-sources are recorded in `dual-source-exports.baseline.json` + * — a shrink-only ratchet. A NEW dual-source name fails this gate; an entry + * that stops being dual-source (converged or renamed) fails until its baseline + * line is deleted, so the ledger cannot quietly stop ratcheting. Fix a new + * finding by NOT introducing the second declaration: import the existing one + * and re-export it, or pick a different name. Growing the baseline is a + * deliberate act that shows up in review as a baseline diff. + * + * ## Usage + * + * pnpm --filter @objectstack/spec check:dual-source-exports # self-test + audit + * tsx scripts/check-dual-source-exports.ts --update # rewrite baseline (review the diff!) + * tsx scripts/check-dual-source-exports.ts --self-test # fixture check only + * + * Reads the built dist — run after `pnpm --filter @objectstack/spec build`. + * The declaration bundler emits each source module into exactly one output + * chunk, so distinct dist declarations imply distinct source declarations; the + * self-test pins the detector itself, and the count assertions keep a silent + * resolution failure from reading as "clean". + */ +import ts from 'typescript'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const BASELINE_PATH = resolve(PKG_DIR, 'dual-source-exports.baseline.json'); +const SELF_TEST = process.argv.includes('--self-test'); +const UPDATE = process.argv.includes('--update'); + +/** Public entry points → their built CJS `.d.ts`, read from the exports map. */ +function collectEntries(): Record { + const pkg = JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')); + const entries: Record = {}; + for (const [sub, val] of Object.entries(pkg.exports ?? {})) { + if (!sub.startsWith('.')) continue; + const dts = val?.require?.types ?? val?.import?.types; + if (typeof dts === 'string' && dts.endsWith('.d.ts')) entries[sub] = resolve(PKG_DIR, dts); + } + return entries; +} + +function kindOf(flags: ts.SymbolFlags): string { + if (flags & ts.SymbolFlags.Function) return 'function'; + if (flags & ts.SymbolFlags.Class) return 'class'; + if (flags & ts.SymbolFlags.Enum) return 'enum'; + if (flags & ts.SymbolFlags.Interface) return 'interface'; + if (flags & ts.SymbolFlags.TypeAlias) return 'type'; + if (flags & ts.SymbolFlags.Variable) return 'const'; + if (flags & ts.SymbolFlags.Namespace) return 'namespace'; + return 'other'; +} + +type ScanResult = { + /** Stable line per dual-source name: `Name — [./a, ./b (kind)] ≠ [./c (kind)]`. */ + findings: string[]; + /** Total distinct export names seen across all entries. */ + names: number; + /** Names on ≥2 entries that resolved to ONE symbol — the benign re-exports. */ + reExports: number; +}; + +/** + * Group every entry's exports by name, then partition each name's entries by + * the ORIGINAL symbol they resolve to. One partition = re-export; two or more + * = dual-source. The finding line encodes the partition (which entries share a + * declaration), not declaration positions — chunk file names carry content + * hashes and would churn the baseline on every build. + */ +function scan(program: ts.Program, entries: Record): ScanResult { + const checker = program.getTypeChecker(); + const unalias = (s: ts.Symbol): ts.Symbol => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + // name → (original symbol → entries exporting it under that name) + const byName = new Map>(); + + for (const [sub, file] of Object.entries(entries)) { + const sf = program.getSourceFile(file); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + if (!moduleSym) throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`); + for (const exported of checker.getExportsOfModule(moduleSym)) { + const name = exported.getName(); + const original = unalias(exported); + let groups = byName.get(name); + if (!groups) byName.set(name, (groups = new Map())); + let subs = groups.get(original); + if (!subs) groups.set(original, (subs = [])); + subs.push(sub); + } + } + + const result: ScanResult = { findings: [], names: byName.size, reExports: 0 }; + for (const [name, groups] of byName) { + const multiEntry = [...groups.values()].some((subs) => subs.length > 1) || groups.size > 1; + if (groups.size === 1) { + if (multiEntry) result.reExports++; + continue; + } + const parts = [...groups.entries()] + .map(([sym, subs]) => `[${subs.sort().join(', ')} (${kindOf(sym.getFlags())})]`) + .sort(); + result.findings.push(`${name} — ${parts.join(' ≠ ')}`); + } + result.findings.sort(); + return result; +} + +function makeProgram(files: string[], extra: ts.CompilerOptions = {}): ts.Program { + return ts.createProgram(files, { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + noEmit: true, + ...extra, + }); +} + +// ── Self-test ──────────────────────────────────────────────────────────────── + +/** + * Pin both edges: a true dual-source must be flagged (a false negative makes + * the gate dormant — green forever, indistinguishable from clean), and a + * re-export must NOT be (a false positive drowns the signal in the ~80 + * legitimate re-exports the real surface carries). + */ +function selfTest(): never { + const fail = (msg: string): never => { + console.error(`✗ self-test: ${msg}`); + process.exit(1); + }; + + const dir = mkdtempSync(join(tmpdir(), 'spec-dual-source-')); + try { + // shared.ts — the single-source declarations both entries re-export. + writeFileSync(join(dir, 'shared.ts'), [ + `export type SharedType = { a: string };`, + `export const sharedConst = 1;`, + ].join('\n'), 'utf8'); + // Entry A: re-exports shared, declares its own TrueDup + Mixed (type). + writeFileSync(join(dir, 'a.ts'), [ + `export { SharedType, sharedConst } from './shared';`, + `export type TrueDup = { fromA: true };`, + `export type Mixed = { a: string };`, + `export type OnlyA = { onlyA: true };`, + ].join('\n'), 'utf8'); + // Entry B: re-exports shared, declares its own TrueDup + Mixed (const) — + // the type-vs-const face of the same trap. + writeFileSync(join(dir, 'b.ts'), [ + `export type { SharedType } from './shared';`, + `export { sharedConst } from './shared';`, + `export type TrueDup = { fromB: true };`, + `export const Mixed = { a: 'b' };`, + `export type OnlyB = { onlyB: true };`, + ].join('\n'), 'utf8'); + + const entries = { './a': join(dir, 'a.ts'), './b': join(dir, 'b.ts') }; + const program = makeProgram(Object.values(entries)); + const syntactic = program.getSyntacticDiagnostics(); + if (syntactic.length > 0) fail(`fixture does not parse: ${ts.flattenDiagnosticMessageText(syntactic[0].messageText, ' ')}`); + + const { findings, names, reExports } = scan(program, entries); + const flagged = new Set(findings.map((f) => f.split(' — ')[0])); + + // 6 distinct names (SharedType, sharedConst, TrueDup, Mixed, OnlyA, OnlyB). + // Fewer means exports are not resolving, and every assertion below would + // pass vacuously — the exact way a gate goes dormant. + if (names !== 6) fail(`saw ${names} export names, expected 6 — the fixture's modules are not resolving`); + if (reExports !== 2) fail(`saw ${reExports} re-exported names, expected 2 (SharedType, sharedConst) — alias resolution is broken`); + + for (const name of ['TrueDup', 'Mixed']) { + if (!flagged.has(name)) fail(`missed \`${name}\` — two declarations share the name and the gate is DORMANT`); + } + for (const name of ['SharedType', 'sharedConst', 'OnlyA', 'OnlyB']) { + if (flagged.has(name)) fail(`false positive on \`${name}\` — only same-name DIFFERENT-declaration exports may be flagged`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + console.log('✅ self-test: flags same-name different-declaration exports, and nothing else.'); + process.exit(0); +} + +if (SELF_TEST) selfTest(); + +// ── Audit ──────────────────────────────────────────────────────────────────── + +const entries = collectEntries(); +const { findings, names, reExports } = scan(makeProgram(Object.values(entries)), entries); + +interface Baseline { _comment: string; entries: string[] } + +const BASELINE_COMMENT = + 'Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more ' + + 'public entry points export for DIFFERENT declarations, so which type a consumer gets depends on ' + + 'the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of ' + + 'one declaration from many entries is fine and not listed). A NEW name here fails ' + + 'check:dual-source-exports: converge on one declaration and re-export it, or rename one side — ' + + 'growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that ' + + 'stops being dual-source fails until its line is deleted. Regenerate with: ' + + 'tsx scripts/check-dual-source-exports.ts --update (after pnpm build).'; + +if (UPDATE) { + const doc: Baseline = { _comment: BASELINE_COMMENT, entries: findings }; + writeFileSync(BASELINE_PATH, JSON.stringify(doc, null, 2) + '\n'); + console.log(`Wrote ${findings.length} dual-source entr${findings.length === 1 ? 'y' : 'ies'} to dual-source-exports.baseline.json — review the diff before committing.`); + process.exit(0); +} + +let baseline: Baseline; +try { + baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +} catch { + console.error(`No baseline at ${BASELINE_PATH}. Run \`tsx scripts/check-dual-source-exports.ts --update\` after a build and commit it.`); + process.exit(1); +} + +const known = new Set(baseline.entries); +const current = new Set(findings); +const fresh = findings.filter((f) => !known.has(f)); +const stale = baseline.entries.filter((e) => !current.has(e)); + +if (fresh.length === 0 && stale.length === 0) { + console.log( + `✅ no new dual-source exports: ${names} names across ${Object.keys(entries).length} entry points — ` + + `${reExports} re-exported (single declaration), ${findings.length} accepted dual-source (baseline).`, + ); + process.exit(0); +} + +if (fresh.length > 0) { + console.error(`❌ ${fresh.length} NEW dual-source export name(s) — two entry points now export the same name for different declarations:\n`); + for (const f of fresh) console.error(` • ${f}`); + console.error( + '\nWhich type a consumer gets now depends on nothing but the import path. An auto-import or a\n' + + 'model completion resolves this by coin-flip, and because such shapes usually overlap, the wrong\n' + + 'pick compiles and fails later at an edge value — the #4411 trap this gate exists to prevent\n' + + '(eleven names were declared twice across ./kernel and ./system, and the copy that LOOKED\n' + + 'canonical was the dead one).\n\n' + + 'Fix it at the declaration, not the ledger:\n' + + ' - if both should be one concept: keep ONE declaration and re-export it from the other entry\n' + + ' (the MetadataManagerConfig pattern — system re-exports kernel’s; a re-export is not flagged);\n' + + ' - if they are genuinely different concepts: one of them is misnamed — rename it.\n\n' + + 'If a maintainer decides a new dual-source must stand, add the line to\n' + + 'dual-source-exports.baseline.json — deliberately, in review, with the reason in the PR.', + ); +} + +if (stale.length > 0) { + console.error(`\n❌ ${stale.length} stale baseline entr${stale.length === 1 ? 'y' : 'ies'} — no longer dual-source, delete the line(s):\n`); + for (const e of stale) console.error(` • ${e}`); + console.error( + '\nThe baseline is shrink-only. A stale line stays available to cover the NEXT same-name collision\n' + + "under the last one's justification, which is how a ratchet quietly stops ratcheting. (If the\n" + + 'partition merely changed shape, the new form is reported above as a new finding — replace the\n' + + 'line, deliberately.)', + ); +} + +process.exit(1); diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 7c5c3cba8c..50c2565faa 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -61,7 +61,10 @@ const GATED: ReadonlyArray<{ check: string; gen: string; artifact: string; reads const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ { check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' }, { check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' }, - { check: 'check:react-conformance', why: 'audits react blocks against their contract — no artifact' }, + { + check: 'check:react-declaration-parity', + why: 'compares the spec schema props against the registry-declared inputs — two declarations, no artifact (and no renderer: #4472)', + }, { check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' }, // Landed in #4177 while this ledger landed in #4183 — neither PR could see the // other, so `main` carried an unclassified script and this reconciliation was @@ -85,6 +88,15 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ check: 'check:exported-any', why: 'audits the built .d.ts for exported types/schemas that resolve to `any` — no artifact (needs a fresh `pnpm build`)', }, + // Reads the built dist like exported-any. Its baseline + // (dual-source-exports.baseline.json) is a shrink-only ledger edited by hand + // under review — deliberately NOT a generated artifact, because a `gen:` that + // rewrites it would admit a new dual-source via "run the fix command" instead + // of via a maintainer decision (#4446). + { + check: 'check:dual-source-exports', + why: 'audits the built .d.ts for same-name exports resolving to DIFFERENT declarations across entry points — baseline is hand-ratcheted, not generated (needs a fresh `pnpm build`)', + }, ]; /** diff --git a/packages/spec/scripts/check-react-blocks-conformance.ts b/packages/spec/scripts/check-react-blocks-conformance.ts deleted file mode 100644 index 5e4d419e16..0000000000 --- a/packages/spec/scripts/check-react-blocks-conformance.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Spec ↔ frontend conformance report (ADR-0081 follow-up). Confirms the -// objectui components ACTUALLY implement the props the spec protocol declares -// for each curated react block. The spec is the protocol; the frontend must -// conform. This surfaces (and can ratchet) the divergence. -// -// - spec-only : the spec schema declares a prop the component does NOT expose -// as a registry input → frontend hasn't implemented the protocol. -// - frontend-only: the component exposes an input the spec does NOT declare → -// undocumented extension (or the spec is behind). -// -// The frontend side is the objectui registry-inputs manifest (sdui.manifest.json, -// produced from the live registry — see objectui scripts/dump-public-manifest.mjs). -// Provide it with MANIFEST=/path/to/sdui.manifest.json. Without it, the check -// reports "manifest unavailable" and exits 0 (same manifest-optional posture as -// the html-tier gate). -// -// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-conformance -// -// Baseline ratchet (cheap CI posture). The full spec↔frontend divergence has an -// accepted baseline (some props are designer-palette-curated, some spec-only are -// soft). Running this on every PR is not worth it — the manifest only exists at -// console-build time. So we instead RATCHET at that point: store the accepted -// per-block frontend-only set, and warn/fail only on NEW divergence. -// -// --baseline compare current state against a committed baseline and -// report only regressions (a block exposes a NEW -// undocumented prop, or a previously-present block vanished). -// --update with --baseline, (re)write the baseline from the current -// manifest instead of comparing. Run after an intentional -// frontend change to accept the new state. -// --strict exit 1 on divergence (plain mode) or regression (baseline). - -process.env.OS_EAGER_SCHEMAS = '1'; - -import fs from 'fs'; -import { z } from 'zod'; -import { REACT_BLOCKS } from '../src/ui/react-blocks'; - -const MANIFEST = process.env.MANIFEST; -const FAIL_ON_DIVERGENCE = process.argv.includes('--strict'); -const UPDATE_BASELINE = process.argv.includes('--update'); -function argValue(flag: string): string | undefined { - const i = process.argv.indexOf(flag); - if (i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) return process.argv[i + 1]; - const inline = process.argv.find((a) => a.startsWith(`${flag}=`)); - return inline ? inline.slice(flag.length + 1) : undefined; -} -const BASELINE = argValue('--baseline'); - -function specProps(schema: any): string[] { - try { - let js: any = z.toJSONSchema(schema, { unrepresentable: 'any' } as any); - if (js?.$ref && js?.$defs) js = js.$defs[String(js.$ref).split('/').pop()!] ?? js; - return Object.keys(js?.properties ?? {}).filter((k) => !['aria', 'type', 'id', 'className', 'style'].includes(k)); - } catch { - return []; - } -} - -function manifestInputs(manifest: any, schemaType: string): string[] | null { - const comps = manifest?.components ?? manifest ?? {}; - // keys may be bare ('object-form') or namespaced ('plugin-form:object-form'). - const entry = - comps[schemaType] ?? - Object.entries(comps).find(([k]) => k === schemaType || k.endsWith(`:${schemaType}`))?.[1]; - if (!entry) return null; - const inputs = (entry as any).inputs ?? []; - return inputs.map((i: any) => i?.name).filter(Boolean); -} - -if (!MANIFEST || !fs.existsSync(MANIFEST)) { - console.log('⚠ react-blocks conformance: manifest unavailable (set MANIFEST=…) — skipping.'); - process.exit(0); -} - -const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); -let totalSpecOnly = 0; -let totalMissingComp = 0; -const overlay = (b: (typeof REACT_BLOCKS)[number]) => new Set(b.interactions.map((i) => i.name)); - -// Per-block snapshot of the actionable signal we ratchet on: the frontend-only -// prop set (component exposes, spec does not declare) and whether the block is -// missing from the manifest entirely. -type BlockState = { frontendOnly: string[]; missing: boolean }; -const current: Record = {}; - -console.log('# Spec ↔ frontend conformance (react blocks)\n'); -for (const b of REACT_BLOCKS) { - if (!b.schema) continue; - const spec = new Set(specProps(b.schema)); - const inputs = manifestInputs(manifest, b.schemaType); - if (inputs === null) { - console.log(`✗ <${b.tag}> (${b.schemaType}): NO component in the manifest — not registered or not public.`); - totalMissingComp++; - current[b.tag] = { frontendOnly: [], missing: true }; - continue; - } - const inputSet = new Set(inputs); - const ov = overlay(b); - const specOnly = [...spec].filter((p) => !inputSet.has(p) && !ov.has(p)); - const frontendOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p)); - const matched = [...spec].filter((p) => inputSet.has(p)); - totalSpecOnly += specOnly.length; - current[b.tag] = { frontendOnly: frontendOnly.slice().sort(), missing: false }; - const status = specOnly.length === 0 ? '✓' : '⚠'; - console.log(`${status} <${b.tag}> (${b.schemaType}): ${matched.length} matched, ${specOnly.length} spec-only, ${frontendOnly.length} frontend-only`); - if (specOnly.length) console.log(` spec declares but component lacks: ${specOnly.join(', ')}`); - if (frontendOnly.length) console.log(` component exposes but spec lacks: ${frontendOnly.join(', ')}`); -} -console.log(`\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the frontend.`); - -// ── Baseline ratchet ───────────────────────────────────────────────────────── -if (BASELINE) { - type Baseline = { blocks: Record }; - if (UPDATE_BASELINE) { - const out: Baseline = { blocks: current }; - fs.writeFileSync( - BASELINE, - JSON.stringify( - { - _comment: - 'Accepted spec↔frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=… check:react-conformance --baseline --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.', - ...out, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - console.log(`\n✓ wrote conformance baseline → ${BASELINE} (${Object.keys(current).length} blocks)`); - process.exit(0); - } - - if (!fs.existsSync(BASELINE)) { - console.error(`\n✗ baseline not found: ${BASELINE} — generate it with --update first.`); - process.exit(FAIL_ON_DIVERGENCE ? 1 : 0); - } - const baseline: Baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); - const regressions: string[] = []; - for (const [tag, state] of Object.entries(current)) { - const base = baseline.blocks?.[tag]; - const baseFO = new Set(base?.frontendOnly ?? []); - const newFO = state.frontendOnly.filter((p) => !baseFO.has(p)); - if (newFO.length) regressions.push(`<${tag}>: new frontend-only prop(s) not in baseline: ${newFO.join(', ')}`); - if (state.missing && base && !base.missing) regressions.push(`<${tag}>: block vanished from the manifest (was present in baseline).`); - } - // A brand-new block in the registry that isn't in the baseline is fine (purely - // additive coverage); we only ratchet against accepted blocks regressing. - console.log('\n## Baseline ratchet'); - if (!regressions.length) { - console.log('✓ no new divergence vs accepted baseline.'); - process.exit(0); - } - console.log('⚠ NEW divergence vs accepted baseline:'); - for (const r of regressions) console.log(` - ${r}`); - console.log( - '\n → If intentional (frontend added a prop / the spec is meant to follow), either declare it in the spec\n' + - ' schema, add it to the block overlay in packages/spec/src/ui/react-blocks.ts, or accept it by\n' + - ' rerunning with --update.', - ); - process.exit(FAIL_ON_DIVERGENCE ? 1 : 0); -} - -if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) { - console.error('Conformance check failed (--strict).'); - process.exit(1); -} diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts new file mode 100644 index 0000000000..a052722d32 --- /dev/null +++ b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins WHAT `check-react-blocks-declaration-parity` claims, not only what it +// computes (#4472). +// +// The script's defect was never its arithmetic — the set difference it prints +// was always right. The defect was the sentence wrapped around it: it opened by +// saying it "confirms the objectui components ACTUALLY implement the props the +// spec protocol declares", which is a statement about renderers, and it has +// never read a renderer. It reads two DECLARATIONS: the spec zod schema on one +// side, and on the other the objectui registry config's `inputs` — copied +// verbatim into the manifest by `manifestFromConfigs`, so also a declaration. +// +// A claim that outruns the capability is worse than no claim: with no gate a +// human checks by hand, and #4413's four dead `record:*` blocks were in fact +// found by hand — while this check reported `{ frontendOnly: [], missing: false }` +// for every one of them, for that defect's entire lifetime, because both +// declarations agreed and neither was lying. Only the renderer was, and the +// renderer is not in scope here. +// +// So these tests assert three things in one process run: +// 1. the signals it CAN see, in both directions (spec-only / registry-only); +// 2. the scope caveat rides along with EVERY report, success included — +// whoever forms a belief from this gate is reading a CI log, not a header; +// 3. the implementation claim stays gone. This is the executable half of +// Prime Directive #10 ("never advertise a capability the runtime doesn't +// deliver"): the wording that caused #4472 fails a test if it comes back. + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(HERE, '..'); +const SCRIPT = path.join(HERE, 'check-react-blocks-declaration-parity.ts'); +const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx'); + +type ManifestInput = { name: string }; +type Manifest = { components: Record }; + +/** Build a manifest declaring exactly `inputs` for `type`. */ +const manifestFor = (type: string, inputs: string[]): Manifest => ({ + components: { [type]: { type, inputs: inputs.map((name) => ({ name })) } }, +}); + +/** + * Every `run()` spawns a fresh tsx process that loads the whole spec schema + * surface — ~4.5s alone and 5–7s under turbo's parallel test load, which is + * ON the 5s vitest default. Which test crossed the line varied run to run: + * three consecutive `pnpm test` sweeps failed a different `it` of this file + * each time, every one a timeout, while the file alone stayed green. A + * timeout here should mean "the script hung", not "the runner was busy". + */ +const SPAWN_TIMEOUT_MS = 60_000; + +/** Run the real script against a synthetic manifest; return stdout+stderr. */ +function run(manifest: Manifest, args: string[] = []): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-parity-')); + const file = path.join(dir, 'sdui.manifest.json'); + fs.writeFileSync(file, JSON.stringify(manifest), 'utf8'); + try { + return execFileSync(TSX, [SCRIPT, ...args], { + cwd: PKG, + env: { ...process.env, MANIFEST: file }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e: any) { + // Non-zero exit (e.g. --strict on divergence) still carries the report. + return `${e?.stdout ?? ''}${e?.stderr ?? ''}`; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// `object-form` is a real REACT_BLOCKS entry backed by FormViewSchema. Two of +// its schema props are enough to exercise both directions; the rest of the +// schema simply shows up as spec-only, which is the soft signal. +const SCHEMA_PROP = 'layout'; +const NOT_A_SCHEMA_PROP = 'zzzNotInTheFormViewSchema'; + +describe('check:react-declaration-parity — the signals it can see', () => { + it('reports a registry-declared input the spec does not declare as registry-only', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); + expect(out).toMatch(/registry declares, spec does not: .*zzzNotInTheFormViewSchema/); + }); + + it('reports a spec-declared prop the registry does not declare as spec-only', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('object-form', [])); + expect(out).toMatch(new RegExp(`spec declares, registry does not: .*${SCHEMA_PROP}`)); + }); + + it('reports a block absent from the manifest as missing', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('something-else', [])); + expect(out).toMatch(/ \(object-form\): NO component in the manifest/); + }); +}); + +describe('check:react-declaration-parity — the blind spot is stated, every run (#4413/#4472)', () => { + /** + * The #4413 shape, reconstructed: both sides declare the same prop, so the + * report is clean. Nothing in this run touched a renderer — if `object-form`'s + * renderer stopped reading `layout` tomorrow, this output would not move. That + * is precisely how four `record:*` blocks that rendered "bind a record to + * preview" sat behind a green ratchet. + * + * The assertion is therefore not "it catches this" (it cannot) but "it says so + * while reporting the agreement". + */ + it('calls agreeing declarations agreement — and prints the caveat alongside it', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('object-form', [SCHEMA_PROP])); + expect(out).toMatch(/declared by both/); + expect(out).not.toMatch(/registry declares, spec does not/); + expect(out).toMatch(/compares two DECLARATIONS/); + expect(out).toMatch(/No renderer is inspected/); + expect(out).toMatch(/#4413/); + }); + + it('carries the caveat on a clean baseline ratchet too, where it is easiest to over-read', { timeout: SPAWN_TIMEOUT_MS }, () => { + const baseline = path.join(PKG, 'react-declaration-parity.baseline.json'); + // Every baselined block must be present, or the run reports them vanished + // instead of clean. Each declares only spec props, so registry-only is empty + // — the committed baseline's accepted state. + const manifest: Manifest = { + components: { + ...manifestFor('object-form', [SCHEMA_PROP]).components, + ...manifestFor('list-view', []).components, + ...manifestFor('object-chart', []).components, + }, + }; + const out = run(manifest, ['--baseline', baseline]); + expect(out).toMatch(/no new DECLARATION divergence/); + expect(out).toMatch(/compares two DECLARATIONS/); + }); +}); + +describe('check:react-declaration-parity — the retired claim stays retired (Prime Directive #10)', () => { + /** + * Guards the wording, in the script AND in what it prints. "conformance", + * "implements", "honors" all assert something about the render path; this gate + * observes none of it. If a future edit reaches for them again, #4472 recurs — + * a gate whose name promises more than it checks, trusted accordingly. + * + * Scoped to the prose the reader forms a belief from: the file's own + * explanations of what it cannot do are allowed to name the retired claim (and + * do), so the check runs against the report, plus the script's leading header + * block minus the lines that quote the old claim to correct it. + */ + const CLAIM_WORDS = /\b(actually implements?|conforms? to|conformance)\b/i; + + it('the report never claims the frontend implements anything', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); + expect(out).not.toMatch(CLAIM_WORDS); + }); + + it('the script advertises its scope before its first line of code', () => { + const src = fs.readFileSync(SCRIPT, 'utf8'); + const header = src.slice(0, src.indexOf('process.env.OS_EAGER_SCHEMAS')); + expect(header).toMatch(/DECLARATION PARITY/); + expect(header).toMatch(/It never looks at a renderer|never (?:reads|inspects) a renderer/i); + // The one blind spot, named in the header rather than left to be rediscovered. + expect(header).toMatch(/BOTH SIDES declare and NO RENDERER READS/); + }); +}); diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.ts new file mode 100644 index 0000000000..17c0bbc17b --- /dev/null +++ b/packages/spec/scripts/check-react-blocks-declaration-parity.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Spec ↔ registry DECLARATION PARITY for the curated react blocks (ADR-0081/ +// ADR-0082 follow-up; corrected by #4472). +// +// ⚠️ READ THIS BEFORE TRUSTING A GREEN RUN — what this does NOT do. +// +// This compares TWO DECLARATIONS. It never looks at a renderer: +// +// left the props the SPEC zod schema declares for a block (`z.toJSONSchema`) +// right the inputs the objectui REGISTRY CONFIG declares for the same block +// — read out of `sdui.manifest.json`, which objectui builds with +// `manifestFromConfigs(getPublicConfigs())`; that function copies +// `config.inputs` verbatim, so the right-hand side is a *declaration* +// too, not evidence that anything consumes the prop. +// +// So a prop that BOTH SIDES declare and NO RENDERER READS is, to this script, +// perfect agreement. Neither declaration is lying on its own; the lie is one +// layer below, where the render happens — and that layer is outside this +// script's field of view entirely. +// +// That blind spot is not hypothetical. This file used to open by claiming it +// "confirms the objectui components ACTUALLY implement the props the spec +// protocol declares". It never could. #4413 is what that cost: four blocks +// (`record:details` / `record:highlights` / `record:related_list` / +// `record:path`) published `objectName` / `recordId` that no renderer read — +// those renderers take the record from the record page's shared context — so +// the blocks rendered a "bind a record to preview" placeholder on a +// `kind:'react'` page. Both declarations agreed the whole time, so the baseline +// recorded `{ registryOnly: [], missing: false }` for all four and this check +// stayed green for #4413's entire lifetime. The defect was found by a human +// reading the objectui renderers, not by this script. #4472 is the correction. +// +// WHAT IT DOES SEE (real signals — don't discount them): +// +// - spec-only : the spec schema declares a prop the registry config does +// NOT declare as an input → the designer palette can't +// configure it. A soft signal per ADR-0082 §2 (the palette +// is deliberately a subset), reported but never ratcheted. +// - registry-only : the registry config declares an input the spec does NOT +// declare → undocumented extension, or the spec is behind. +// This is what the baseline ratchets on. +// - missing : no component for this block in the manifest at all → not +// registered, or not public. Real signal. +// +// WHAT IT CANNOT SEE is exactly one class: both sides declare it, nothing reads +// it. Catching that needs evidence from the render path (a behavioural probe or +// a renderer-side usage pass), which lives in objectui, not here. +// `check-react-blocks-declaration-parity.test.ts` pins the limitation with a +// fixture that stays green while its "renderer" ignores everything, so the +// capability cannot be re-assumed by the next reader. +// +// The frontend side is the objectui registry-inputs manifest (sdui.manifest.json +// — see objectui scripts/dump-public-manifest.mjs). Provide it with +// MANIFEST=/path/to/sdui.manifest.json. Without it, the check reports "manifest +// unavailable" and exits 0 (same manifest-optional posture as the html-tier gate). +// +// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-declaration-parity +// +// Baseline ratchet (cheap CI posture). The full spec↔registry divergence has an +// accepted baseline (some props are designer-palette-curated, some spec-only are +// soft). Running this on every PR is not worth it — the manifest only exists at +// console-build time. So we instead RATCHET at that point: store the accepted +// per-block registry-only set, and fail only on NEW divergence. +// +// --baseline compare current state against a committed baseline and +// report only regressions (a block declares a NEW +// undocumented input, or a previously-present block vanished). +// --update with --baseline, (re)write the baseline from the current +// manifest instead of comparing. Run after an intentional +// registry change to accept the new state. +// --strict exit 1 on divergence (plain mode) or regression (baseline). + +process.env.OS_EAGER_SCHEMAS = '1'; + +import fs from 'fs'; +import { z } from 'zod'; +import { REACT_BLOCKS } from '../src/ui/react-blocks'; + +const MANIFEST = process.env.MANIFEST; +const FAIL_ON_DIVERGENCE = process.argv.includes('--strict'); +const UPDATE_BASELINE = process.argv.includes('--update'); +function argValue(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + if (i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) return process.argv[i + 1]; + const inline = process.argv.find((a) => a.startsWith(`${flag}=`)); + return inline ? inline.slice(flag.length + 1) : undefined; +} +const BASELINE = argValue('--baseline'); + +/** + * The scope caveat the report carries on EVERY run, success included. + * + * Deliberately in the OUTPUT and not only in this header: whoever forms a belief + * about what "✓ no new divergence" means is reading a CI log, not this file. + * #4472's finding is that the claim travelled further than the capability, so + * the correction has to travel with the result. + */ +const SCOPE_NOTE = + 'Scope: compares two DECLARATIONS — spec zod schema props vs registry-declared inputs.\n' + + ' No renderer is inspected. A prop BOTH sides declare and NO renderer reads counts\n' + + ' as agreement here; that blind spot is what #4413 shipped through (see #4472).'; + +function specProps(schema: any): string[] { + try { + let js: any = z.toJSONSchema(schema, { unrepresentable: 'any' } as any); + if (js?.$ref && js?.$defs) js = js.$defs[String(js.$ref).split('/').pop()!] ?? js; + return Object.keys(js?.properties ?? {}).filter((k) => !['aria', 'type', 'id', 'className', 'style'].includes(k)); + } catch { + return []; + } +} + +function manifestInputs(manifest: any, schemaType: string): string[] | null { + const comps = manifest?.components ?? manifest ?? {}; + // keys may be bare ('object-form') or namespaced ('plugin-form:object-form'). + const entry = + comps[schemaType] ?? + Object.entries(comps).find(([k]) => k === schemaType || k.endsWith(`:${schemaType}`))?.[1]; + if (!entry) return null; + const inputs = (entry as any).inputs ?? []; + return inputs.map((i: any) => i?.name).filter(Boolean); +} + +if (!MANIFEST || !fs.existsSync(MANIFEST)) { + console.log('⚠ react-blocks declaration parity: manifest unavailable (set MANIFEST=…) — skipping.'); + process.exit(0); +} + +const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); +let totalSpecOnly = 0; +let totalMissingComp = 0; +const overlay = (b: (typeof REACT_BLOCKS)[number]) => new Set(b.interactions.map((i) => i.name)); + +// Per-block snapshot of the actionable signal we ratchet on: the registry-only +// input set (the registry config declares it, the spec does not) and whether the +// block is missing from the manifest entirely. +type BlockState = { registryOnly: string[]; missing: boolean }; +const current: Record = {}; + +console.log('# Spec ↔ registry declaration parity (react blocks)\n'); +console.log(SCOPE_NOTE + '\n'); +for (const b of REACT_BLOCKS) { + if (!b.schema) continue; + const spec = new Set(specProps(b.schema)); + const inputs = manifestInputs(manifest, b.schemaType); + if (inputs === null) { + console.log(`✗ <${b.tag}> (${b.schemaType}): NO component in the manifest — not registered or not public.`); + totalMissingComp++; + current[b.tag] = { registryOnly: [], missing: true }; + continue; + } + const inputSet = new Set(inputs); + const ov = overlay(b); + const specOnly = [...spec].filter((p) => !inputSet.has(p) && !ov.has(p)); + const registryOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p)); + const declaredByBoth = [...spec].filter((p) => inputSet.has(p)); + totalSpecOnly += specOnly.length; + current[b.tag] = { registryOnly: registryOnly.slice().sort(), missing: false }; + const status = specOnly.length === 0 ? '✓' : '⚠'; + console.log( + `${status} <${b.tag}> (${b.schemaType}): ${declaredByBoth.length} declared by both, ${specOnly.length} spec-only, ${registryOnly.length} registry-only`, + ); + if (specOnly.length) console.log(` spec declares, registry does not: ${specOnly.join(', ')}`); + if (registryOnly.length) console.log(` registry declares, spec does not: ${registryOnly.join(', ')}`); +} +console.log( + `\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the registry.`, +); +console.log(' "declared by both" is a statement about the two declarations, not about the renderer.'); + +// ── Baseline ratchet ───────────────────────────────────────────────────────── +if (BASELINE) { + type Baseline = { blocks: Record }; + if (UPDATE_BASELINE) { + const out: Baseline = { blocks: current }; + fs.writeFileSync( + BASELINE, + JSON.stringify( + { + _comment: + 'Accepted spec↔registry DECLARATION-PARITY baseline (react blocks). Per block: the registry-only input set (the registry config declares it, the spec does not) and whether the block is missing. Regenerate with: MANIFEST=… check:react-declaration-parity --baseline --update. The ratchet flags only NEW registry-only inputs or newly-missing blocks. It compares two declarations and inspects no renderer, so a prop both sides declare and nothing reads records as agreement here (#4413/#4472).', + ...out, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + console.log(`\n✓ wrote declaration-parity baseline → ${BASELINE} (${Object.keys(current).length} blocks)`); + process.exit(0); + } + + if (!fs.existsSync(BASELINE)) { + console.error(`\n✗ baseline not found: ${BASELINE} — generate it with --update first.`); + process.exit(FAIL_ON_DIVERGENCE ? 1 : 0); + } + const baseline: Baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); + const regressions: string[] = []; + for (const [tag, state] of Object.entries(current)) { + const base = baseline.blocks?.[tag]; + const baseRO = new Set(base?.registryOnly ?? []); + const newRO = state.registryOnly.filter((p) => !baseRO.has(p)); + if (newRO.length) regressions.push(`<${tag}>: new registry-only input(s) not in baseline: ${newRO.join(', ')}`); + if (state.missing && base && !base.missing) regressions.push(`<${tag}>: block vanished from the manifest (was present in baseline).`); + } + // A brand-new block in the registry that isn't in the baseline is fine (purely + // additive coverage); we only ratchet against accepted blocks regressing. + console.log('\n## Baseline ratchet'); + if (!regressions.length) { + console.log('✓ no new DECLARATION divergence vs accepted baseline (see the scope note above).'); + process.exit(0); + } + console.log('⚠ NEW declaration divergence vs accepted baseline:'); + for (const r of regressions) console.log(` - ${r}`); + console.log( + '\n → If intentional (the registry added an input / the spec is meant to follow), either declare it\n' + + ' in the spec schema, add it to the block overlay in packages/spec/src/ui/react-blocks.ts, or\n' + + ' accept it by rerunning with --update.', + ); + process.exit(FAIL_ON_DIVERGENCE ? 1 : 0); +} + +if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) { + console.error('Declaration-parity check failed (--strict).'); + process.exit(1); +} diff --git a/packages/spec/scripts/lib/strictness-ledger.ts b/packages/spec/scripts/lib/strictness-ledger.ts index 927d25a76b..ef94bb29e3 100644 --- a/packages/spec/scripts/lib/strictness-ledger.ts +++ b/packages/spec/scripts/lib/strictness-ledger.ts @@ -10,9 +10,23 @@ import fs from 'node:fs'; import path from 'node:path'; -/** `z.object(` occurrences — the ledger's own stated counting method. */ +/** + * Object sites — `z.object(` **or** `strictObject(` — the ledger's own stated + * counting method. + * + * `strictObject(` counts because it *is* an object site; it is what a converted + * schema looks like. Counting only `z.object(` would have made every conversion + * silently shrink the ledger's measured surface, so a directory being solved and + * a directory being deleted would read identically — and a genuinely new, + * un-triaged `strictObject` schema would never register as undeclared surface. + * + * The gate caught this itself on the first conversion (`data/seed.zod.ts`, 1 → 0), + * which is the behaviour to preserve: a change in how schemas are written must + * fail this check rather than quietly rebase what it measures. + */ export function countSites(file: string): number { - return (fs.readFileSync(file, 'utf-8').match(/z\.object\(/g) ?? []).length; + const src = fs.readFileSync(file, 'utf-8'); + return (src.match(/z\.object\(|(? = { + // EMPTY since #4488 paid off all nine debts the map opened with (app, book, + // doc, email_template, job, mapping, seed, translation, validation) — every + // registered type is governed. The map stays because the ratchet is the + // point, not the entries: registering a NEW type without a ledger fails CI + // with instructions to either govern it or record the debt here (reason + + // issue number). Do not add an entry just to silence the gate. +}; // Spec-only override: governed types whose canonical schema is NOT (yet) in the // metadata-type registry, so they can't be resolved via getMetadataTypeSchema. @@ -135,7 +166,18 @@ function unwrap(s: any, depth = 0): any { if (!def) return s; if (def.type === 'lazy' && typeof def.getter === 'function') return unwrap(def.getter(), depth + 1); if (['optional', 'default', 'nullable', 'readonly', 'catch', 'nonoptional', 'prefault'].includes(def.type)) return unwrap(def.innerType, depth + 1); - if (def.type === 'pipe') return unwrap(def.in ?? def.out, depth + 1); + if (def.type === 'pipe') { + // Two pipes, opposite authorable sides. `a.pipe(b)` authors against the IN + // side (a is the accepted input shape). `z.preprocess(fn, schema)` also + // compiles to a pipe, but its IN side is the preprocess TRANSFORM — the + // authorable surface is the OUT schema. Until #4488 this branch always took + // `def.in`, so a preprocess-wrapped registration (TranslationItemSchema's + // retired-dialect guard, #3778) unwrapped to the transform, walked to no + // shape, and made `translation` ungovernable. + const inDef = defOf(unwrap(def.in, depth + 1)); + if (inDef?.type === 'transform') return unwrap(def.out, depth + 1); + return unwrap(def.in ?? def.out, depth + 1); + } return s; } function shapeOf(s: any): Record | null { @@ -221,6 +263,8 @@ const report: any = { proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts orphanEntries: [] as string[], // a ledger row whose property is gone from the schema (the reverse direction) + ungoverned: [] as string[], // a REGISTERED metadata type absent from both GOVERNED and PENDING_GOVERNANCE + stalePending: [] as string[], // a PENDING_GOVERNANCE row for a type that is now governed / no longer registered verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist evidenceLocal: 0, // repo-rooted evidence paths actually resolved against this checkout evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here @@ -348,13 +392,31 @@ const staleDays = Number(staleDaysArg?.split('=')[1]) || DEFAULT_STALE_DAYS; const showWorklist = staleDaysArg !== undefined; report.verification = buildVerificationReport(verificationEntries, { staleDays }); +// ── coverage: is every REGISTERED metadata type accounted for? ── +// The gate's own blind spot until #4487. Everything above asks "is every +// property of a governed type classified?" — nothing asked "is every authorable +// type governed?", so a type absent from GOVERNED was never in the denominator +// and its silence read as success. +const governedSet = new Set(GOVERNED); +report.ungoverned = listMetadataTypeSchemaTypes() + .filter((t) => !governedSet.has(t) && !(t in PENDING_GOVERNANCE)) + .sort(); +// A PENDING_GOVERNANCE row for a type that is now governed (or no longer +// registered) is the same rot as an orphan ledger row: it claims a debt that +// does not exist, and it makes the map's length a lie about how much is left. +report.stalePending = Object.keys(PENDING_GOVERNANCE) + .filter((t) => governedSet.has(t) || !listMetadataTypeSchemaTypes().includes(t)) + .sort(); + const totalUnclassified = report.unclassified.length; const totalProofFailures = report.proofErrors.length + report.proofMissing.length; const failed = totalUnclassified > 0 || totalProofFailures > 0 || report.orphanEntries.length > 0 || - report.verification.errors.length > 0; + report.verification.errors.length > 0 || + report.ungoverned.length > 0 || + report.stalePending.length > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -389,6 +451,30 @@ if (asJson) { console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.json:`); report.unclassified.forEach((s: string) => console.log(` ${s}`)); } + if (report.ungoverned.length) { + console.log(`\n✗ ${report.ungoverned.length} REGISTERED metadata type(s) governed by nothing:`); + report.ungoverned.forEach((t: string) => console.log(` ${t}`)); + console.log( + '\n These are authorable — `/api/v1/meta/types/:type` serves them and Studio edits\n' + + ' them — but no ledger asks who reads their properties, so an inert key on one is\n' + + ' invisible to CI. `datasource` sat here for its whole life and cost six inert keys\n' + + ' found by hand, two of them security-shaped (#4410, #4465, #4481).\n\n' + + ' Either govern the type (add it to GOVERNED and seed packages/spec/liveness/.json\n' + + ' — see the seeding aid: `tsx check-liveness.mts --dump `), or record the debt in\n' + + " PENDING_GOVERNANCE with a reason AND an issue number. Do not pick the second option\n" + + ' just to get green: an entry with no issue behind it is indistinguishable from never\n' + + ' having looked, which is the state this gate exists to end.', + ); + } + if (report.stalePending.length) { + console.log(`\n✗ ${report.stalePending.length} stale PENDING_GOVERNANCE row(s) — the debt is already paid:`); + report.stalePending.forEach((t: string) => console.log(` ${t}`)); + console.log( + '\n The type is now governed (or no longer registered), so the row claims a debt that\n' + + ' no longer exists and overstates how much coverage work is left. Delete it — same\n' + + ' rot as an orphan ledger row, opposite direction.', + ); + } if (report.orphanEntries.length) { console.log(`\n✗ ${report.orphanEntries.length} ORPHAN ledger row(s) — the property is gone from the schema:`); report.orphanEntries.forEach((s: string) => console.log(` ${s}`)); @@ -418,10 +504,17 @@ if (asJson) { } else if (v.stale.length || v.unverified.length) { console.log(' run with --stale-verification[=days] for the worklist.'); } + const pendingCount = Object.keys(PENDING_GOVERNANCE).length; + if (pendingCount) { + console.log( + `\ncoverage: ${GOVERNED.length} type(s) governed, ${pendingCount} registered type(s) awaiting a ledger ` + + `(${Object.keys(PENDING_GOVERNANCE).sort().join(', ')}) — a worklist, not a merge gate.`, + ); + } if (!failed) { console.log( - '\n✓ all governed-type properties are classified, no ledger row outlives its property, ' + - 'and all bound high-risk proofs resolve.', + '\n✓ all governed-type properties are classified, every registered type is governed or ' + + 'explicitly pending, no ledger row outlives its property, and all bound high-risk proofs resolve.', ); } } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 1116fc4573..e94bfffc0a 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -205,6 +205,18 @@ "to": "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158)", "conversionId": "flow-node-wait-timeout-keys-removed", "toMajor": 17 + }, + { + "surface": "datasource.readReplicas", + "to": "datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", + "conversionId": "datasource-read-replicas-removed", + "toMajor": 17 + }, + { + "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", + "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", + "conversionId": "flow-node-script-branch-keys-removed", + "toMajor": 17 } ], "migrated": [ @@ -347,6 +359,13 @@ "migrationId": "query-distinct-retired", "toMajor": 17, "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", + "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", + "migrationId": "workflow-service-slot-retired", + "toMajor": 17, + "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451." } ], "removed": [] @@ -681,6 +700,18 @@ "to": "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158)", "conversionId": "flow-node-wait-timeout-keys-removed", "toMajor": 17 + }, + { + "surface": "datasource.readReplicas", + "to": "datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", + "conversionId": "datasource-read-replicas-removed", + "toMajor": 17 + }, + { + "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", + "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", + "conversionId": "flow-node-script-branch-keys-removed", + "toMajor": 17 } ], "migrated": [ @@ -753,6 +784,13 @@ "migrationId": "query-distinct-retired", "toMajor": 17, "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", + "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", + "migrationId": "workflow-service-slot-retired", + "toMajor": 17, + "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451." } ], "removed": [] diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index a926fef9e2..70511090a3 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -178,8 +178,10 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ /** Base URL for Package Management */ packages: z.string().optional().describe('e.g. /api/v1/packages'), - /** Base URL for Workflow Engine */ - workflow: z.string().optional().describe('e.g. /api/v1/workflow'), + // `workflow` was removed here (#4451, v17): no host ever mounted a workflow + // surface and nothing ever registered the slot (ADR-0115 Evidence 5), so no + // builder could truthfully populate the field. State machines are enforced + // by the `state_machine` validation rule; approvals live below. /** Base URL for Approvals (ADR-0019: approval as a flow node) */ approvals: z.string().optional().describe('e.g. /api/v1/approvals'), diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 2c7a37e0cc..74620c52c3 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -96,6 +96,8 @@ export const ERROR_CODE_LEDGER = { 'REPORT_SAVE_FAILED', 'REPORT_SCHEDULE_FAILED', 'REQUEST_NOT_FOUND', + 'RESUME_FAILED', // decision recorded but its flow run could not be resumed + 'RESUME_TARGET_LOST', // the flow run behind the request no longer exists 'RULE_DEFINE_FAILED', 'RULE_DELETE_FAILED', 'RULE_EVALUATE_FAILED', @@ -282,6 +284,9 @@ export const ERROR_CODE_LEDGER = { 'INVALID_SIGNAL', // resume signal writes engine-internal variables 'NODE_FAILURE', 'NO_EXECUTOR', + 'RESUME_IN_PROGRESS', // duplicate resume refused while the first is running + 'RUN_NOT_FOUND', // no suspension for this run id — unresumable for good + 'STORE_UNAVAILABLE', // durable suspended-run store unreadable — existence unknown ], '@objectstack/service-analytics': [ 'CUBE_NOT_FOUND', diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index fecd761a81..d0e0073421 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -81,7 +81,9 @@ export const RestApiRouteCategory = z.enum([ 'permission', // Permission/authorization checks 'analytics', // Analytics and reporting 'automation', // Automation triggers and flows - 'workflow', // Workflow state management + // 'workflow' removed (#4451, v17): no workflow surface ever existed for a + // route to belong to (ADR-0115 Evidence 5); state machines are a validation + // rule, approvals are flow nodes. Routes in that space are 'automation'. 'ui', // UI metadata (views, layouts) 'realtime', // Realtime/WebSocket 'notification', // Notification management diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 6990d26137..5533727535 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -28,12 +28,7 @@ import { GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetEffectivePermissionsResponseSchema, - // Workflows - GetWorkflowConfigRequestSchema, - WorkflowStateSchema, - GetWorkflowStateRequestSchema, - WorkflowTransitionRequestSchema, - WorkflowTransitionResponseSchema, + // Workflow schemas removed with the retired slot (#4451, v17) // Realtime RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, @@ -219,27 +214,9 @@ describe('ObjectStack Protocol', () => { } }); - it('validates Workflow operations', () => { - expect(GetWorkflowConfigRequestSchema.safeParse({ object: 'lead' }).success).toBe(true); - const state = { - currentState: 'open', - availableTransitions: [ - { name: 'approve', targetState: 'approved', label: 'Approve', requiresApproval: true }, - ], - history: [{ - fromState: 'draft', toState: 'open', action: 'submit', - userId: 'u1', timestamp: '2024-01-15T10:00:00Z', - }], - }; - expect(WorkflowStateSchema.safeParse(state).success).toBe(true); - expect(GetWorkflowStateRequestSchema.safeParse({ object: 'lead', recordId: 'l1' }).success).toBe(true); - expect(WorkflowTransitionRequestSchema.safeParse({ - object: 'lead', recordId: 'l1', transition: 'approve', comment: 'Looks good', - }).success).toBe(true); - expect(WorkflowTransitionResponseSchema.safeParse({ - object: 'lead', recordId: 'l1', success: true, state, - }).success).toBe(true); - }); + // 'validates Workflow operations' removed (#4451, v17): the workflow + // schemas were deleted with the retired slot — nothing ever implemented + // the protocol they described. it('validates Realtime operations', () => { expect(RealtimeConnectRequestSchema.safeParse({ diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 33d3f5c62c..e8c46afaa1 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -21,7 +21,6 @@ import { } from './analytics.zod'; import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; -import { StateMachineSchema } from '../automation/state-machine.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; import { TranslationDataSchema } from '../system/translation.zod'; import { @@ -684,67 +683,20 @@ export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({ })); // ========================================== -// Workflow Operations +// Workflow Operations — REMOVED (#4451, v17) // ========================================== - -export const GetWorkflowConfigRequestSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name to get workflow config for'), -})); - -export const GetWorkflowConfigResponseSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name'), - workflows: z.array(StateMachineSchema).describe('Active state-machine workflows for this object'), -})); - -export const WorkflowStateSchema = lazySchema(() => z.object({ - currentState: z.string().describe('Current workflow state name'), - availableTransitions: z.array(z.object({ - name: z.string().describe('Transition name'), - targetState: z.string().describe('Target state after transition'), - label: z.string().optional().describe('Display label'), - requiresApproval: z.boolean().default(false).describe('Whether transition requires approval'), - })).describe('Available transitions from current state'), - history: z.array(z.object({ - fromState: z.string().describe('Previous state'), - toState: z.string().describe('New state'), - action: z.string().describe('Action that triggered the transition'), - userId: z.string().describe('User who performed the action'), - timestamp: z.string().datetime().describe('When the transition occurred'), - comment: z.string().optional().describe('Optional comment'), - })).optional().describe('State transition history'), -})); - -export const GetWorkflowStateRequestSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID to get workflow state for'), -})); - -export const GetWorkflowStateResponseSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - state: WorkflowStateSchema.describe('Current workflow state and available transitions'), -})); - -export const WorkflowTransitionRequestSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - transition: z.string().describe('Transition name to execute'), - comment: z.string().optional().describe('Optional comment for the transition'), - data: z.record(z.string(), z.unknown()).optional().describe('Additional data for the transition'), -})); - -export const WorkflowTransitionResponseSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - success: z.boolean().describe('Whether the transition succeeded'), - state: WorkflowStateSchema.describe('New workflow state after transition'), -})); - -// ADR-0019: approval is no longer a workflow step. The approve/reject surface -// moved off `workflow` onto the dedicated approvals runtime (a flow's Approval -// node opens a request and suspends; a decision resumes it). Decisions are -// recorded via `POST /approvals/requests/:id/{approve,reject}`, not on a -// workflow record. `workflow` is reclaimed for state machines (transitions). +// The Get/WorkflowState/Config/Transition schemas and the `WorkflowProtocol` +// interface were deleted: no code ever implemented any of the three methods, +// nothing ever registered the `workflow` service slot they notionally fronted +// (ADR-0115 Evidence 5 — "no code in this repository resolves either slot", +// verified across both repositories), and no HTTP surface ever mounted +// `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among +// routes that never existed). The capability the wrappers promised is live +// elsewhere: record state machines are enforced by the `state_machine` +// validation rule (`StateMachineSchema` stays authorable on the object), +// approvals are first-class flow nodes on the approvals runtime (ADR-0019 — +// decisions via `POST /approvals/requests/:id/{approve,reject}`), and +// record-triggered automation is lifecycle hooks + `record_change` flows. // ========================================== // Realtime Operations @@ -1312,14 +1264,8 @@ export type GetObjectPermissionsResponse = z.infer; export type GetEffectivePermissionsResponse = z.infer; -// Workflow Types -export type GetWorkflowConfigRequest = z.input; -export type GetWorkflowConfigResponse = z.infer; -export type WorkflowState = z.infer; -export type GetWorkflowStateRequest = z.input; -export type GetWorkflowStateResponse = z.infer; -export type WorkflowTransitionRequest = z.input; -export type WorkflowTransitionResponse = z.infer; +// Workflow Types — removed with the schemas (#4451, v17); see the block comment +// at "Workflow Operations — REMOVED" above. // Realtime Types export type RealtimeConnectRequest = z.input; @@ -1498,12 +1444,9 @@ export interface PermissionProtocol { getEffectivePermissions?(request: GetEffectivePermissionsRequest): Promise; } -/** Workflows (optional). */ -export interface WorkflowProtocol { - getWorkflowConfig?(request: GetWorkflowConfigRequest): Promise; - getWorkflowState?(request: GetWorkflowStateRequest): Promise; - workflowTransition?(request: WorkflowTransitionRequest): Promise; -} +// `WorkflowProtocol` (optional sub-protocol) was removed here (#4451, v17): +// no implementation of any of its three methods ever existed. See the +// "Workflow Operations — REMOVED" block comment above. /** Realtime / presence (optional). */ export interface RealtimeProtocol { diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index ffadc9cb56..37dcff7db0 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -90,7 +90,8 @@ export const RouterConfigSchema = lazySchema(() => z.object({ storage: z.string().default('/storage').describe('Storage Protocol'), analytics: z.string().default('/analytics').describe('Analytics Protocol'), ui: z.string().default('/ui').describe('UI Metadata Protocol (Views, Layouts)'), - workflow: z.string().default('/workflow').describe('Workflow Engine Protocol'), + // `workflow` mount removed (#4451, v17): no workflow surface ever existed + // to mount (ADR-0115 Evidence 5). realtime: z.string().default('/realtime').describe('Realtime/WebSocket Protocol'), notifications: z.string().default('/notifications').describe('Notification Protocol'), ai: z.string().default('/ai').describe('AI Engine Protocol (NLQ, Chat, Suggest)'), @@ -104,7 +105,6 @@ export const RouterConfigSchema = lazySchema(() => z.object({ storage: '/storage', analytics: '/analytics', ui: '/ui', - workflow: '/workflow', realtime: '/realtime', notifications: '/notifications', ai: '/ai', diff --git a/packages/spec/src/automation/flow-function.test.ts b/packages/spec/src/automation/flow-function.test.ts index 613b9accdd..a0a0ddbab5 100644 --- a/packages/spec/src/automation/flow-function.test.ts +++ b/packages/spec/src/automation/flow-function.test.ts @@ -69,6 +69,24 @@ describe('FlowFunctionEntrySchema', () => { it('rejects a declaration whose handler is not callable', () => { expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(false); }); + + // #4343 — what `objectstack build` produces. The CLI lowers every inline + // callable to a serialisable ref BEFORE the stack is parsed, so a built + // manifest holds `{ scoreLead: 'scoreLead' }`. Rejecting that made + // `defineStack({ functions })` — a documented, first-class mechanism — + // unbuildable, which #4343 turned from latent into blocking by making + // `config.function` the only thing a `script` node can run. + it('accepts a lowered handler ref, the form a built artifact carries', () => { + expect(FlowFunctionEntrySchema.safeParse('scoreLead').success).toBe(true); + // Empty is not a name. + expect(FlowFunctionEntrySchema.safeParse('').success).toBe(false); + }); + + it('drops a lowered ref when normalizing — it names a function without carrying one', () => { + // The callable for that name comes from the sidecar ESM module the build + // emits; binding the string would register a name pointing at nothing. + expect(normalizeFlowFunctionEntry('scoreLead')).toBeUndefined(); + }); }); describe('defineStack({ functions }) — the authoring surface (#4396)', () => { diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index a5ce3ab843..59fc7a7fb8 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -112,13 +112,33 @@ export type FlowFunctionDeclaration = z.infer; /** - * One entry of the `functions` map as authors may write it: the handler alone - * (pure), or a {@link FlowFunctionDeclarationSchema} that states its effect. + * One entry of the `functions` map: the handler alone (pure), a + * {@link FlowFunctionDeclarationSchema} that states its effect, or the + * **lowered handler ref** a built artifact carries. + * + * The first two are what an author writes. The third is what `objectstack + * build` produces and was, until #4343, the reason `defineStack({ functions })` + * could not survive a build at all: the CLI lowers every inline callable to a + * serialisable string ref BEFORE the stack is parsed (it must — `z.function()` + * wraps callables and would break the ref mapping), so the manifest reaching + * this schema holds `{ myFn: 'myFn' }`, which neither of the other two members + * accepts. The build failed on a mechanism its own docs call first-class. + * + * A string entry carries no callable, and that is correct rather than lossy: + * the real functions ride in the sibling ESM module esbuild emits, and + * {@link collectBundleFunctionEntries} merges both sources by name. The string + * is the artifact's record that the NAME exists — which is why + * {@link normalizeFlowFunctionEntry} deliberately drops it (see there). + * + * Authoring a string by hand therefore registers nothing. It fails loudly, not + * silently: a `script` node naming it refuses at execute with "no function + * named '…' is registered" (#1870). */ export const FlowFunctionEntrySchema = lazySchema(() => z.union([ z.function(), FlowFunctionDeclarationSchema, -]).describe('A named handler function, or a declaration record stating its effect')); + z.string().min(1).describe('A lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'), +]).describe('A named handler function, a declaration record stating its effect, or a lowered handler ref')); export type FlowFunctionEntry = z.infer; @@ -153,6 +173,12 @@ export function isFlowFunctionEffect(value: unknown): value is FlowFunctionEffec * Deliberately hand-written rather than a `FlowFunctionEntrySchema.parse()`: * the entry holds a live function, and the collectors that call this run on the * boot path where re-parsing every handler buys nothing. + * + * A lowered string ref (the third member of that schema) returns `undefined` + * here BY DESIGN — it names a function without carrying one. The callable for + * that name comes from the built sidecar module, which the same collector + * merges in; treating the string as an entry would register a name bound to + * nothing. */ export function normalizeFlowFunctionEntry(entry: unknown): NormalizedFlowFunction | undefined { if (typeof entry === 'function') { diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 36cd232e22..b736d65580 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -25,11 +25,34 @@ * * This ledger is the declared source both validators now read, mirroring the * dispatcher↔client route ledger of #3569: one list, two consumers, plus a - * reconciliation test that fails when a descriptor's `configSchema` declares an - * `xExpression` property this ledger does not carry + * reconciliation test that fails when a declared `xExpression` property this + * ledger does not carry appears anywhere * (`config-expression-ledger.test.ts` in `service-automation`). A new expression * key can no longer be added to a designer form and silently go unvalidated. * + * ## The two channels a slot can be declared through + * + * A node type declares its config contract one of two ways, and the ratchet + * reads both (#4439): + * + * - **descriptor `configSchema`** — the JSON-Schema literal an executor + * publishes. Its `xExpression` properties are enumerable from the live + * registry. + * - **`schemaless-node-config.zod.ts`** — `script` / `subflow` / `decision` + * publish NO descriptor `configSchema` on purpose (a published partial + * schema would drop the editors their hand-written forms need — the #4210 + * incident), so their contract is a Zod schema in that module and the marker + * rides `.meta({ xExpression })` through `z.toJSONSchema`, the same channel + * `loop.collection` already used. + * + * Until #4439 only the first channel was read, and because the ratchet also + * fails on a ledger entry no channel declares, a schemaless node's expression + * slot could not be entered here even deliberately. `decision`'s + * `conditions[].expression` sat in exactly that hole: declared bare CEL by its + * own schema and its own comments, walked by neither validator, so a `{…}` + * predicate passed `objectstack validate` and surfaced only when the flow ran + * (#4414 made that run-time failure loud; this makes it a build failure). + * * ## Why the dialect must be recorded, not assumed * * `xExpression` takes two values that mean **opposite** things about braces, and @@ -97,7 +120,18 @@ export interface FlowNodeExpressionPath { * * Not listed here (deliberately): `config.condition` and `edge.condition`. Those * are *structural* predicate surfaces on every node and edge rather than - * descriptor-declared config properties, and both validators already walk them. + * declared config properties, and both validators already walk them. + * + * Also deliberately absent: config values that merely INTERPOLATE `{token}` + * templates — `script.inputs` / `script.variables` / `subflow.input`, + * `notify.body`, `create_record.fields.*` and so on. Those are text-with-holes, + * the shape essentially every node config string has, already covered + * generically (`validate-flow-template-paths`, the CLI flow linter's + * `collectTemplateStrings`). A `flow-template` ledger entry means something + * narrower: a slot whose value is a *reference that must resolve to a value*, + * like `loop.collection`. The #4439 sweep of the schemaless class found exactly + * one genuinely declared expression slot — `decision.conditions[].expression` — + * and `script.template` is a template **id**, not a template body. */ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ { @@ -106,6 +140,15 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ role: 'predicate', label: 'screen field visibleWhen', }, + { + // Declared through the schemaless channel — `decision` publishes no + // descriptor `configSchema`, so the marker lives on + // `DecisionConditionSchema.expression`'s `.meta()` (#4439). + nodeType: 'decision', + path: 'conditions[].expression', + role: 'predicate', + label: 'decision branch expression', + }, { nodeType: 'loop', path: 'collection', diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 7dc6a1b1c1..ff3b35aad5 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -33,12 +33,12 @@ export const FlowNodeAction = z.enum([ 'get_record', // CRUD: Get/Query 'http', // Outbound HTTP callout (ADR-0018 M3) — canonical; outbox-backed when durable 'notify', // Outbound notification (ADR-0012) — dispatched via the messaging service - 'script', // Custom action: a built-in side-effect (`config.actionType: 'email'|'slack'`) - // or a registered function (`config.function: 'name'` + `config.inputs`), - // resolved from `defineStack({ functions })`. (Inline `config.script` JS is - // recognized but NOT executed by the built-in runtime — no server-side - // sandbox.) A script node naming none of these is flagged at build and - // fails loudly at run time (#1870). + 'script', // Custom action: call the registered function named by `config.function` + // (+ `config.inputs`), resolved from `defineStack({ functions })`. The key + // is REQUIRED — a node naming no callable is flagged at build and refused + // at execute (#1870). The `actionType` dispatch branches (logger-backed + // 'email'/'slack', inline `config.script`) were retired in 17 (#4343): + // use `notify` / a connector / a registered function instead. 'screen', // Screen / User-Input Element 'wait', // Delay/Sleep 'subflow', // Call another flow @@ -113,9 +113,12 @@ export const FlowVariableSchema = lazySchema(() => z.object({ * type: "decision", * label: "Is High Value?", * config: { + * // Bare CEL — braces are the #1491 trap and fail at registration. + * // Each `label` must match an out-edge's `label` to route anywhere; + * // when nothing matches, the `isDefault` out-edge is the fallback. * conditions: [ - * { label: "Yes", expression: "{amount} > 10000" }, - * { label: "No", expression: "true" } // default + * { label: "Yes", expression: "amount > 10000" }, + * { label: "No", expression: "true" } // catch-all, NOT the default path * ] * }, * position: { x: 300, y: 200 } @@ -315,11 +318,25 @@ export const FlowEdgeSchema = lazySchema(() => z.object({ /** * Default Sequence Flow marker (BPMN Default Flow semantics). - * When true, this edge is taken when no sibling conditional edges match. - * Only meaningful on outgoing edges of decision/gateway nodes. + * + * When true, this edge is traversed only when NO sibling conditional edge of + * the same source node matched — the "otherwise" branch. A default edge is + * therefore not part of the unconditional parallel fan-out; when a conditional + * sibling wins, this edge's target records a `skipped` step instead. + * + * Enforced by `AutomationEngine.traverseNext` since #4414. It had promised + * exactly this since it was declared and had **zero readers** for as long: an + * author who marked the fallback edge got an ordinary unconditional edge that + * ran on every pass, alongside whichever branch actually matched. Combining it + * with `condition` on the same edge is self-contradictory (BPMN forbids a + * conditional default flow) and is flagged by the `os build` / `os validate` + * flow linter, as is a second default edge out of the same node. */ isDefault: z.boolean().default(false) - .describe('Marks this edge as the default path when no other conditions match'), + .describe( + 'BPMN default flow: traverse this edge only when no sibling conditional edge of the same ' + + 'source node matched. Mutually exclusive with `condition`; at most one per source node.', + ), }, { error: flowEdgeUnknownKeyError }).strict()); /** diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 7abfa49c33..f44fe4518a 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -13,7 +13,14 @@ export * from './execution.zod'; export * from './webhook.zod'; export * from './approval.zod'; export * from './etl.zod'; -export * from './trigger-registry.zod'; +// `trigger-registry.zod` was removed here (#4499). Despite the filename it +// contained no trigger registry — all 630 lines were a third declaration of +// the connector vocabulary (ConnectorSchema, Authentication*, Operation*, +// ConnectorInstance…), self-contained and read by nothing: the automation +// engine registers connectors against `integration/connector.zod.ts` +// (ADR-0097), and the stack `connectors:` collection parses +// DeclarativeConnectorEntrySchema. One capability, one contract +// (Prime Directive #12); the #4480 template cluster fell the same way. export * from './time-relative-trigger.zod'; export * from './sync.zod'; export * from './state-machine.zod'; diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts new file mode 100644 index 0000000000..84eec4198b --- /dev/null +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two schemaless node contracts that are PARSED at execute time (#4343). + * + * `script` and `subflow` run through `service-automation`'s `parseNodeConfig()` + * before their executors do anything, so what this file pins is not decoration: + * a shape accepted here runs, and a shape rejected here refuses the node as a + * guard. `decision` is deliberately absent — it stays export-only (its one key + * is optional, so a parse would have nothing to check). + * + * The structural assertions at the bottom guard the downstream walkers that a + * union-shaped contract would have broken, which is why #4343 converged the + * node instead of modelling its branches: the authorable-surface ratchet, the + * expression ledger and objectui's reconciliation all read a FLAT + * `properties` / `.shape`. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + ScriptConfigSchema, + SubflowConfigSchema, + getSchemalessNodeConfigJsonSchemas, +} from './schemaless-node-config.zod.js'; + +/** Every key the contract still declares, tombstones included. */ +const SCRIPT_SHAPE_KEYS = [ + 'actionType', 'function', 'inputs', 'outputVariable', + 'recipients', 'script', 'template', 'variables', +]; +/** The keys #4343 retired — each must reject with its own prescription. */ +const SCRIPT_RETIRED: ReadonlyArray<[string, unknown]> = [ + ['actionType', 'email'], + ['template', 'task_done'], + ['recipients', ['{record.owner}']], + ['variables', { taskName: '{record.name}' }], + ['script', 'return { ok: true };'], +]; + +describe('ScriptConfigSchema (#4343 — converged to a function call)', () => { + it('accepts the one shape the executor runs', () => { + expect(ScriptConfigSchema.parse({ + function: 'score_lead', + inputs: { leadId: '{record.id}' }, + outputVariable: 'score', + })).toEqual({ + function: 'score_lead', + inputs: { leadId: '{record.id}' }, + outputVariable: 'score', + }); + }); + + it('accepts a bare `function` — inputs and outputVariable stay optional', () => { + expect(ScriptConfigSchema.parse({ function: 'score_lead' })).toEqual({ function: 'score_lead' }); + }); + + it('requires `function`: a script node that names no callable has nothing to run', () => { + const empty = ScriptConfigSchema.safeParse({}); + expect(empty.success).toBe(false); + expect(empty.error!.issues[0]!.path).toEqual(['function']); + + // Same for a present-but-empty name — `.min(1)`, not just "declared". + expect(ScriptConfigSchema.safeParse({ function: '' }).success).toBe(false); + }); + + it.each(SCRIPT_RETIRED)('rejects the retired `%s` with its own prescription', (key, value) => { + const result = ScriptConfigSchema.safeParse({ function: 'score_lead', [key]: value }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + // The tombstone's payload is the prescription, not "unrecognized key" — + // this string IS the upgrade doc for whoever hits it (retired-key.ts). + expect(message).toContain(`\`script.config.${key}\``); + expect(message).toMatch(/#4343/); + expect(message).toMatch(/os migrate meta --from 16/); + expect(result.error!.issues[0]!.path).toEqual([key]); + }); + + it('names every violated key at once, so one refusal lists the whole job', () => { + const result = ScriptConfigSchema.safeParse({ + function: 'score_lead', actionType: 'email', template: 't', recipients: ['a'], + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path[0]).sort()) + .toEqual(['actionType', 'recipients', 'template']); + }); + + it('prescribes a different mechanism per branch — the retirement is not one rename', () => { + const messageFor = (key: string, value: unknown) => + ScriptConfigSchema.safeParse({ function: 'f', [key]: value }).error!.issues[0]!.message; + // Mail has a real delivery path; Slack does not go through it (no slack + // channel exists — that is a connector), and an inline body is a function. + expect(messageFor('actionType', 'email')).toMatch(/`notify` node/); + expect(messageFor('actionType', 'email')).toMatch(/connector_action/); + expect(messageFor('script', 'return 1;')).toMatch(/defineStack\(\{ functions \}\)/); + }); +}); + +describe('SubflowConfigSchema (#4343 — parsed at execute time)', () => { + it('accepts the executor-read shape', () => { + expect(SubflowConfigSchema.parse({ + flowName: 'escalation_flow', + input: { caseId: '{record.id}' }, + outputVariable: 'subResult', + })).toEqual({ + flowName: 'escalation_flow', + input: { caseId: '{record.id}' }, + outputVariable: 'subResult', + }); + }); + + it('refuses a missing or empty `flowName` — the step cannot pick a flow', () => { + for (const bad of [{}, { flowName: '' }]) { + const result = SubflowConfigSchema.safeParse(bad); + expect(result.success, JSON.stringify(bad)).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['flowName']); + } + }); +}); + +describe('structural contract — what the downstream walkers require', () => { + it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => { + // A `retiredKey()` is still a property. Deleting it outright would read as + // "the key vanished" to the authorable-surface gate, which is the hard + // failure the tombstone route exists to avoid. + expect(Object.keys(ScriptConfigSchema.shape).sort()).toEqual(SCRIPT_SHAPE_KEYS); + for (const [key] of SCRIPT_RETIRED) { + expect(ScriptConfigSchema.shape[key as keyof typeof ScriptConfigSchema.shape].description) + .toMatch(/^\[REMOVED\]/); + } + }); + + it('stays a FLAT JSON Schema — no anyOf/oneOf for a union-blind walker to miss', () => { + const json = getSchemalessNodeConfigJsonSchemas().script as Record; + expect(Object.keys(json.properties as object).sort()).toEqual(SCRIPT_SHAPE_KEYS); + for (const combinator of ['anyOf', 'oneOf', 'allOf']) { + expect(json[combinator], `top-level ${combinator} would blind the authorable-surface walk`) + .toBeUndefined(); + } + expect((json.required as string[])).toEqual(['function']); + }); + + it('still converts without throwing, tombstones and all', () => { + expect(() => z.toJSONSchema(SubflowConfigSchema, { unrepresentable: 'any' })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 084d7d4e0e..a91a2e69eb 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -11,14 +11,18 @@ * * `config-schemas.test.ts` in `service-automation` pins the schemaless class * with each member's reason: `decision`'s virtual Target column is derived from - * the out-edges, `script`'s form switches on `actionType`, `subflow` carries a - * top-level `timeoutMs` — a published partial schema would DROP those editors - * (the #4210 `connector_action` incident). So the Studio form for these types - * is objectui's hand-written group, and until #4278 **nothing reconciled that - * hand-written table against the executors**: `script`'s form offered an - * `outputVariables` key nothing reads, two `actionType` options that fail every - * run, a no-op default — and could not author the `function`/`inputs`/ - * `outputVariable` path that works. + * the out-edges, `subflow` carries a top-level `timeoutMs` — a published + * partial schema would DROP those editors (the #4210 `connector_action` + * incident). So the Studio form for these types is objectui's hand-written + * group, and until #4278 **nothing reconciled that hand-written table against + * the executors**: `script`'s form offered an `outputVariables` key nothing + * reads, two `actionType` options that fail every run, a no-op default — and + * could not author the `function`/`inputs`/`outputVariable` path that works. + * + * `script`'s own reason for staying schemaless was that its form switched on + * `actionType`. #4343 retired that switch, so the node is now three flat keys + * and could graduate to a published descriptor `configSchema` the way `map` + * did — a follow-up, deliberately not folded into the retirement. * * These schemas are the machine-readable half of that reconciliation. They are * **written from the executors** (`service-automation/builtin/screen-nodes.ts` @@ -34,24 +38,37 @@ * {@link FlowNodeSchema} (`waitEventConfig` / `connectorConfig`), which the * same objectui test reconciles directly. * - * ## What these schemas are (and are not) wired to - * - * Contract exports only — no engine path `parse()`s a node config with them, - * so registering a flow behaves exactly as before. This is where they differ - * from their `builtin-node-config.zod.ts` siblings, which #4277 wired into - * execute-time parsing (`service-automation`'s `parse-config.ts`) and into the - * `registerFlow()` unknown-key rejection. - * - * That difference is deliberate, and it is the same reason these three publish - * no descriptor `configSchema`: **their key set is not the whole contract.** - * `script`'s legal keys depend on `actionType` (a built-in side effect reads - * `template`/`recipients`/`variables`; the function path reads - * `function`/`inputs`/`outputVariable`), and `decision` may carry no - * `conditions` at all when it branches purely on edge predicates. A flat parse - * would either reject those shapes or wave everything through — neither is the - * contract. Wiring them in needs a discriminated form first; until then the - * enforcement they DO get is the objectui reconciliation test, which is what - * #4278 was actually about (a form authoring keys nothing reads). + * ## What these schemas are wired to + * + * `script` and `subflow` are **parsed at execute time** since #4343, through + * the same `parseNodeConfig()` seam #4277 gave the flat builtins + * (`service-automation`'s `parse-config.ts`): a config that fails its contract + * refuses the node as a GUARD — wrong metadata, so a rerun cannot help and no + * `fault` edge may route it (#3863). + * + * `script` could not be parsed while its legal key set depended on + * `actionType`; #4343 removed that dependence instead of modelling it. + * Converging the node to its one real path — call a registered function — left + * a flat three-key contract a flat parse fits exactly, and the five keys the + * other branches read became {@link retiredKey} tombstones. + * + * The two halves reach different audiences, which is why they shipped together: + * + * - the **tombstones** teach whoever authors the key — `tsc` types it `never`, + * and a direct parse raises the prescription. They do NOT reach a stored + * flow: `FlowNodeSchema.config` is `z.record(z.unknown())`, so no load-path + * parse ever descends into a node's config; + * - the **execute-time parse** is what a stored flow meets. `registerFlow` + * canonicalizes data at rest through the retired conversion too (#3903), so + * a stored `actionType: 'email'` node arrives here stripped of the keys + * nothing read — and then refuses, naming the `function` it does not have, + * instead of logging a line and reporting success as it used to. + * + * `decision` stays export-only, deliberately: it may carry no `conditions` at + * all when it branches purely on edge predicates (a plain BPMN exclusive + * gateway), and `conditions` is its only key — so a parse would have nothing + * left to check. Its enforcement remains the objectui reconciliation test, + * which is what #4278 was actually about (a form authoring keys nothing reads). * * Undeclared aliases are NOT part of these contracts: `subflow`'s historical * `flow` spelling graduated into the ADR-0087 D2 conversion @@ -61,87 +78,106 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; // ─── script ────────────────────────────────────────────────────────── -/** - * `script` action types with a built-in (logger-backed) side-effect handler. - * Any other non-marker `actionType` is treated as a registered-function name - * (#1870). The executor builds its dispatch set from THIS constant, and the - * designer's `actionType` options must stay within - * `[SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, ...SCRIPT_BUILTIN_ACTION_TYPES]` — - * the #4278 drift was the form offering `sms` / `notification`, which are in - * neither set and so failed every run as unresolvable function names. - */ -export const SCRIPT_BUILTIN_ACTION_TYPES = ['email', 'slack'] as const; -export type ScriptBuiltinActionType = (typeof SCRIPT_BUILTIN_ACTION_TYPES)[number]; - -/** - * The `actionType` MARKER meaning "call the registered function named by - * `config.function`" — it is not itself a function name. The executor fails - * the step with a clear message when this marker is set and `function` is not. - */ -export const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = 'invoke_function'; - /** * `script` node config — what the executor reads (screen-nodes.ts). * - * Dispatch precedence, verbatim from the executor: - * - * 1. `function` set → resolve and call that registered function (always wins). - * 2. `actionType` ∈ {@link SCRIPT_BUILTIN_ACTION_TYPES} → the built-in - * logger-backed side effect, fed by `template` / `recipients` / `variables`. - * 3. `script` set (no `function`) → **recognized but NOT executed**: the - * built-in runtime has no server-side JS sandbox, so the node warns loudly - * and completes as a no-op. Authoring is steered to a registered function. - * 4. Anything left in `actionType` (except the - * {@link SCRIPT_INVOKE_FUNCTION_ACTION_TYPE} marker) is shorthand for a - * function name; a name that resolves to nothing fails the step LOUDLY. + * **One shape, one path (#4343):** a `script` node names a registered function + * (`defineStack({ functions })`), passes it `inputs`, and binds its return + * value to `outputVariable`. `function` is required — a script node that names + * no callable has nothing to run, and the execute-time parse refuses it rather + * than letting the run discover that halfway through. * * The invoked function is contractually PURE — it returns its result and the * flow graph persists it (`FlowFunctionEffectSchema`, #4396). The descriptor * publishes that as `handlerContract: 'pure'`, and it is what lets the node - * report no record metrics without guessing. + * report no record metrics without guessing. A function that legitimately + * writes declares `effect: 'writes'` where it is registered, so the run reports + * an effect it cannot count instead of reporting none. + * + * ## What the four other shapes were, and why they are gone + * + * Until #4343 the legal key set depended on `actionType`, which is why this + * contract could not be parsed at all (see the module header). Of the four + * dispatch branches only the function path ran real logic: + * + * - `actionType: 'email' | 'slack'` were **logger-backed stubs**. They wrote a + * line to the log, reported success, and delivered nothing under any + * configuration — `template` / `recipients` / `variables` fed a message no + * channel ever sent. `notify` (real delivery, via the messaging service) and + * `connector_action` were already the live mechanisms. + * - `script` (inline JS) was **recognized but never executed**: the built-in + * runtime has no server-side JS sandbox, so the node warned and no-op'd. + * - any other `actionType` was **shorthand for a function name** — a second + * spelling of `function`, and the `invoke_function` marker named nothing on + * its own. + * + * All five keys are tombstoned below; the ADR-0087 D2 conversion + * `flow-node-script-branch-keys-removed` rewrites stored sources (moving a + * shorthand `actionType` into `function`, where that is what it meant). */ export const ScriptConfigSchema = lazySchema(() => z.object({ - /** Built-in side-effect id, the `invoke_function` marker, or (shorthand) a registered-function name. */ - actionType: z.string().optional() - .describe("How this step runs: a built-in side effect ('email' | 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name"), /** - * Registered function to call (`defineStack({ functions })`) — always wins - * over `actionType`. + * Registered function to call (`defineStack({ functions })`) — required: it + * is the whole of what a `script` node does. * * Contractually pure: it takes `inputs`, RETURNS a value, and does no data * I/O of its own. A function that legitimately writes declares * `effect: 'writes'` where it is registered, so the run reports an effect it * cannot count instead of reporting none (#4396). */ - function: z.string().optional() - .describe('Registered function to call (defineStack({ functions })); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists'), + function: z.string().min(1) + .describe('Registered function to call (defineStack({ functions })). Contractually pure — it returns a value a later declarative node persists'), /** Inputs passed to the function; values interpolate `{token}` templates against the live flow variables. */ inputs: z.record(z.string(), z.unknown()).optional() .describe('Inputs passed to the function (values interpolate {token} templates)'), /** Flow variable the function's RETURN value is bound to (pure-function pattern — data I/O stays on the graph). */ outputVariable: z.string().optional() .describe("Flow variable the function's return value is bound to"), - /** Built-in side effects only: message template id. */ - template: z.string().optional() - .describe('Built-in side effects only: message template id'), - /** Built-in side effects only: recipient list (user ids, field refs, addresses). */ - recipients: z.array(z.string()).optional() - .describe('Built-in side effects only: recipients (user ids, field refs, or addresses)'), - /** Built-in side effects only: values injected into the template. */ - variables: z.record(z.string(), z.unknown()).optional() - .describe('Built-in side effects only: values injected into the template'), - /** - * Inline JS source — recognized but NOT executed by the built-in runtime (no - * server-side JS sandbox): the node warns and completes as a no-op. Kept in - * the contract because the executor reads it; deliberately NOT offered for - * new authoring (the designer renders a stored value read-only-style and - * steers authors to `function`). - */ - script: z.string().optional() - .describe('Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead'), + + // The four retired dispatch branches (#4343). Each tombstone carries its own + // prescription because the three replacements are different mechanisms, not + // one rename: real messaging is `notify`, Slack is a connector, and inline + // logic belongs in a registered function. + actionType: retiredKey( + '`script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values ' + + 'did what it said. The two built-ins were logger-backed stubs that recorded the intent and ' + + 'delivered nothing under any configuration, and every other value was a second spelling of ' + + '`config.function`. Replace it per branch: for `email` use a `notify` node (it delivers ' + + 'through the messaging service — the in-app inbox by default, real email once ' + + '`@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with ' + + 'the Slack connector, or an `http` node posting to a webhook; for anything else, move the ' + + 'name into `config.function`. Run `os migrate meta --from 16` to rewrite it automatically.', + ), + template: retiredKey( + '`script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the ' + + 'logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template ' + + 'id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and ' + + 'stored templates live in the messaging service (`sys_notification_template`), not on the ' + + 'node. Run `os migrate meta --from 16` to rewrite it automatically.', + ), + recipients: retiredKey( + '`script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were ' + + 'logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a ' + + '`notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging ' + + 'service for real. Run `os migrate meta --from 16` to rewrite it automatically.', + ), + variables: retiredKey( + '`script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values ' + + 'into a template no side effect ever rendered. Delete the key. A `notify` node carries ' + + 'structured data in `payload`; a registered function takes it in `config.inputs`. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.', + ), + script: retiredKey( + '`script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has ' + + 'no server-side JS sandbox, so an inline body was recognized and never executed: the node ' + + 'warned and completed as a no-op. Move the logic into a registered function ' + + '(`defineStack({ functions })`) and name it in `config.function`. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.', + ), })); export type ScriptConfig = z.input; @@ -152,8 +188,11 @@ export type ScriptConfigParsed = z.infer; /** * `subflow` node config — what the executor reads (subflow-node.ts). * - * `flowName` is execute-time required (the step is refused without it). The - * historical undeclared `flow` alias is NOT part of this contract: the + * `flowName` is execute-time required: since #4343 the executor parses this + * contract before it runs, so a missing or empty name refuses the node as a + * guard (wrong metadata — a rerun cannot supply it) instead of failing through + * a hand-written check. The historical undeclared `flow` alias is NOT part of + * this contract: the * ADR-0087 D2 conversion `flow-node-subflow-flow-alias` rewrites it at load * (#4278 — the `map.flow` graduation path), so the executor only ever sees * `flowName`. The node-level `timeoutMs` lives on {@link FlowNodeSchema}, not @@ -161,7 +200,7 @@ export type ScriptConfigParsed = z.infer; */ export const SubflowConfigSchema = lazySchema(() => z.object({ /** The flow to invoke (execute-time required). */ - flowName: z.string().describe('Flow invoked as this step (it may pause — approval / screen / wait)'), + flowName: z.string().min(1).describe('Flow invoked as this step (it may pause — approval / screen / wait)'), /** Values passed to the child's input variables; `{token}` templates resolve against the parent's variables. */ input: z.record(z.string(), z.unknown()).optional() .describe("Values passed to the subflow's input variables (interpolate {token} templates)"), @@ -178,8 +217,14 @@ export type SubflowConfigParsed = z.infer; /** * One `decision` branch — what the executor reads per condition * (logic-nodes.ts): the first branch whose bare-CEL `expression` evaluates - * true wins, and the run continues down the out-edge labelled `label` - * (no match → the edge labelled `default`). + * true wins, and the run continues down the out-edge labelled `label`. When no + * branch matches, the run takes the declared fallback — the out-edge marked + * `isDefault: true`, or one literally labelled `default`. + * + * `label` must match an out-edge's `label` **exactly**. A label nothing claims + * cannot route: traversal logs a warning and falls back to considering every + * out-edge, and `os validate` reports it as `flow-branch-label-unmatched` + * (#4414 — every decision label in the repo used to miss, silently). * * The designer's branch rows also show a **Target** column — that is a * VIRTUAL column projected from the node's out-edges by the designer @@ -188,9 +233,23 @@ export type SubflowConfigParsed = z.infer; */ export const DecisionConditionSchema = lazySchema(() => z.object({ /** Branch label — must match an out-edge's `label` to route anywhere. */ - label: z.string().describe("Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path)"), - /** Bare-CEL predicate (ADR-0032) — `{…}` template braces are the #1491 trap. */ - expression: z.string().describe('Bare CEL predicate deciding this branch'), + label: z.string().describe("Branch label; the winning branch resumes down the out-edge with this label (no match → the out-edge marked isDefault, or one labelled 'default')"), + /** + * Bare-CEL predicate (ADR-0032) — `{…}` template braces are the #1491 trap. + * + * `xExpression: 'expression'` is what carries that from a comment into the + * machine-readable contract (#4439): it rides the `.meta()` → JSON-Schema + * channel (same as `loop.collection`'s `'template'` marker), so the + * expression ledger can claim this slot even though `decision` publishes no + * descriptor `configSchema`, and `registerFlow` / `objectstack validate` then + * check it as the bare CEL it is. Before that the declaration was prose only: + * both validators walked a hardcoded list this key was not on, so a + * brace-in-CEL predicate passed the build and was only caught at run time. + */ + expression: z.string().meta({ + description: 'Bare CEL predicate deciding this branch', + xExpression: 'expression', + }), })); export type DecisionCondition = z.input; @@ -198,18 +257,84 @@ export type DecisionCondition = z.input; /** * `decision` node config — what the executor reads. * - * A decision may also carry no `conditions` at all and rely purely on - * condition-bearing OUT-EDGES (`edge.condition`, evaluated by the engine's - * traversal) — that is the legacy shape. The legacy singular - * `config.condition` is a structural surface the engine parse-validates on - * every node at registration but the decision executor never reads; branching - * predicates live in `conditions[]` or on the edges. + * A decision may also carry no `conditions` at all and rely purely on the + * OUT-EDGES (`edge.condition` per branch + `isDefault` on the fallback, + * evaluated by the engine's traversal) — a plain BPMN exclusive gateway, and + * the shape every bundled example uses. A node that declares no `conditions` + * reports no branch at all, so nothing competes with the edges. + * + * Pick **one** mechanism per decision. Declaring `conditions` here *and* + * per-edge `condition`s means the node picks a branch and then that branch's + * edge re-decides — the double-declaration behind #4414. + * + * The legacy singular `config.condition` is a structural surface the engine + * parse-validates on every node at registration but the decision executor never + * reads; branching predicates live in `conditions[]` or on the edges. */ export const DecisionConfigSchema = lazySchema(() => z.object({ - /** Ordered branches; first true expression wins, else the `default`-labelled edge. */ + /** Ordered branches; first true expression wins, else the declared default edge. */ conditions: z.array(DecisionConditionSchema).optional() .describe('Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)'), })); export type DecisionConfig = z.input; export type DecisionConfigParsed = z.infer; + +// ─── registry ──────────────────────────────────────────────────────── + +/** + * Every schemaless builtin's config contract, keyed by `node.type` (#4439). + * + * The descriptor-schema'd builtins can be enumerated at run time — the engine's + * registry hands out their `configSchema`s — but these three publish none by + * design, so anything that wants to reason about *all* node config contracts + * had to name them one by one. That is how the expression ledger's + * reconciliation ratchet ended up structurally unable to cover them: it derives + * its expectation from descriptor `configSchema`s, and a node that has none + * could never own a ledger entry, no matter what its contract declared. + * + * With this map the ratchet reads BOTH channels — descriptor `xExpression` + * markers and these schemas' `.meta({ xExpression })` markers — so a declared + * expression slot is covered wherever it is declared, and a stale ledger entry + * still fails from either side. + * + * Additive: objectui's `flow-node-config` reconciliation imports each schema by + * name and is unaffected. + */ +export const SCHEMALESS_NODE_CONFIG_SCHEMAS = { + script: ScriptConfigSchema, + subflow: SubflowConfigSchema, + decision: DecisionConfigSchema, +} as const satisfies Record; + +/** Node types whose config contract lives in this module rather than a descriptor. */ +export type SchemalessNodeType = keyof typeof SCHEMALESS_NODE_CONFIG_SCHEMAS; + +/** + * {@link SCHEMALESS_NODE_CONFIG_SCHEMAS} as JSON Schema, memoized — the same + * shape a descriptor's `configSchema` is, so a consumer can read both channels + * with one walk instead of two notions of "a declared config property" (#4439). + * + * Derived in `input` mode like {@link getApprovalNodeConfigJsonSchema}, which + * is what carries `.meta({ xExpression })` markers through verbatim. + * + * These are **not** published on a descriptor — that is the whole point of the + * schemaless class (see this module's header) — so nothing here reaches the + * Studio property form. It exists so validation ledgers and reconciliation + * ratchets can see these contracts at all. + */ +let cachedSchemalessNodeConfigJsonSchemas: Readonly> | undefined; +export function getSchemalessNodeConfigJsonSchemas(): Readonly> { + if (cachedSchemalessNodeConfigJsonSchemas === undefined) { + const out = {} as Record; + for (const [nodeType, schema] of Object.entries(SCHEMALESS_NODE_CONFIG_SCHEMAS)) { + out[nodeType as SchemalessNodeType] = z.toJSONSchema(schema, { + target: 'draft-2020-12', + io: 'input', + unrepresentable: 'any', + }); + } + cachedSchemalessNodeConfigJsonSchemas = out; + } + return cachedSchemalessNodeConfigJsonSchemas; +} diff --git a/packages/spec/src/automation/trigger-registry.test.ts b/packages/spec/src/automation/trigger-registry.test.ts deleted file mode 100644 index adab8d27f2..0000000000 --- a/packages/spec/src/automation/trigger-registry.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - ConnectorCategorySchema, - AuthenticationTypeSchema, - AuthFieldSchema, - OAuth2ConfigSchema, - AuthenticationSchema, - OperationTypeSchema, - OperationParameterSchema, - ConnectorOperationSchema, - ConnectorTriggerSchema, - ConnectorSchema, - ConnectorInstanceSchema, - Connector, -} from './trigger-registry.zod'; - -describe('ConnectorCategorySchema', () => { - it('should accept all valid categories', () => { - const categories = [ - 'crm', 'payment', 'communication', 'storage', 'analytics', - 'database', 'marketing', 'accounting', 'hr', 'productivity', - 'ecommerce', 'support', 'devtools', 'social', 'other', - ]; - categories.forEach(c => { - expect(() => ConnectorCategorySchema.parse(c)).not.toThrow(); - }); - }); - - it('should reject invalid category', () => { - expect(() => ConnectorCategorySchema.parse('invalid')).toThrow(); - }); -}); - -describe('AuthenticationTypeSchema', () => { - it('should accept all valid auth types', () => { - const types = ['none', 'apiKey', 'basic', 'bearer', 'oauth1', 'oauth2', 'custom']; - types.forEach(t => { - expect(() => AuthenticationTypeSchema.parse(t)).not.toThrow(); - }); - }); - - it('should reject invalid auth type', () => { - expect(() => AuthenticationTypeSchema.parse('saml')).toThrow(); - }); -}); - -describe('AuthFieldSchema', () => { - it('should accept valid auth field with defaults', () => { - const result = AuthFieldSchema.parse({ - name: 'api_key', - label: 'API Key', - }); - expect(result.type).toBe('text'); - expect(result.required).toBe(true); - }); - - it('should accept full auth field', () => { - const field = { - name: 'region', - label: 'Region', - type: 'select' as const, - description: 'Cloud region', - required: false, - default: 'us-east-1', - options: [ - { label: 'US East', value: 'us-east-1' }, - { label: 'EU West', value: 'eu-west-1' }, - ], - placeholder: 'Select a region', - }; - expect(() => AuthFieldSchema.parse(field)).not.toThrow(); - }); - - it('should reject invalid name (not snake_case)', () => { - expect(() => AuthFieldSchema.parse({ - name: 'ApiKey', - label: 'API Key', - })).toThrow(); - }); - - it('should reject missing label', () => { - expect(() => AuthFieldSchema.parse({ - name: 'api_key', - })).toThrow(); - }); -}); - -describe('OAuth2ConfigSchema', () => { - it('should accept valid config with defaults', () => { - const result = OAuth2ConfigSchema.parse({ - authorizationUrl: 'https://example.com/auth', - tokenUrl: 'https://example.com/token', - }); - expect(result.clientIdField).toBe('client_id'); - expect(result.clientSecretField).toBe('client_secret'); - }); - - it('should accept full config', () => { - expect(() => OAuth2ConfigSchema.parse({ - authorizationUrl: 'https://example.com/auth', - tokenUrl: 'https://example.com/token', - scopes: ['read', 'write'], - clientIdField: 'my_client_id', - clientSecretField: 'my_secret', - })).not.toThrow(); - }); - - it('should reject invalid URLs', () => { - expect(() => OAuth2ConfigSchema.parse({ - authorizationUrl: 'not-a-url', - tokenUrl: 'https://example.com/token', - })).toThrow(); - }); -}); - -describe('AuthenticationSchema', () => { - it('should accept minimal auth config', () => { - expect(() => AuthenticationSchema.parse({ - type: 'none', - })).not.toThrow(); - }); - - it('should accept auth with fields and test', () => { - const result = AuthenticationSchema.parse({ - type: 'apiKey', - fields: [{ name: 'api_key', label: 'API Key', type: 'password' }], - test: { url: 'https://api.example.com/me' }, - }); - expect(result.test?.method).toBe('GET'); - }); - - it('should accept oauth2 with config', () => { - expect(() => AuthenticationSchema.parse({ - type: 'oauth2', - oauth2: { - authorizationUrl: 'https://example.com/auth', - tokenUrl: 'https://example.com/token', - }, - })).not.toThrow(); - }); - - it('should reject missing type', () => { - expect(() => AuthenticationSchema.parse({})).toThrow(); - }); -}); - -describe('OperationTypeSchema', () => { - it('should accept all valid types', () => { - const types = ['read', 'write', 'delete', 'search', 'trigger', 'action']; - types.forEach(t => { - expect(() => OperationTypeSchema.parse(t)).not.toThrow(); - }); - }); - - it('should reject invalid type', () => { - expect(() => OperationTypeSchema.parse('execute')).toThrow(); - }); -}); - -describe('OperationParameterSchema', () => { - it('should accept valid param with defaults', () => { - const result = OperationParameterSchema.parse({ - name: 'channel', - label: 'Channel', - type: 'string', - }); - expect(result.required).toBe(false); - }); - - it('should accept full param', () => { - expect(() => OperationParameterSchema.parse({ - name: 'channel', - label: 'Channel', - description: 'Slack channel', - type: 'string', - required: true, - default: '#general', - validation: { pattern: '^#' }, - dynamicOptions: 'loadChannels', - })).not.toThrow(); - }); - - it('should reject missing type', () => { - expect(() => OperationParameterSchema.parse({ - name: 'channel', - label: 'Channel', - })).toThrow(); - }); -}); - -describe('ConnectorOperationSchema', () => { - it('should accept valid operation with defaults', () => { - const result = ConnectorOperationSchema.parse({ - id: 'send_message', - name: 'Send Message', - type: 'action', - }); - expect(result.supportsPagination).toBe(false); - expect(result.supportsFiltering).toBe(false); - }); - - it('should accept full operation', () => { - expect(() => ConnectorOperationSchema.parse({ - id: 'list_contacts', - name: 'List Contacts', - description: 'List all contacts', - type: 'read', - inputSchema: [{ name: 'limit', label: 'Limit', type: 'number' }], - outputSchema: { type: 'array' }, - sampleOutput: [{ name: 'John' }], - supportsPagination: true, - supportsFiltering: true, - })).not.toThrow(); - }); - - it('should reject invalid id (not snake_case)', () => { - expect(() => ConnectorOperationSchema.parse({ - id: 'SendMessage', - name: 'Send Message', - type: 'action', - })).toThrow(); - }); -}); - -describe('ConnectorTriggerSchema', () => { - it('should accept valid webhook trigger', () => { - expect(() => ConnectorTriggerSchema.parse({ - id: 'new_message', - name: 'New Message', - type: 'webhook', - })).not.toThrow(); - }); - - it('should accept polling trigger with interval', () => { - expect(() => ConnectorTriggerSchema.parse({ - id: 'new_record', - name: 'New Record', - type: 'polling', - pollingIntervalMs: 5000, - config: { resource: 'contacts' }, - outputSchema: { type: 'object' }, - })).not.toThrow(); - }); - - it('should reject polling interval below minimum', () => { - expect(() => ConnectorTriggerSchema.parse({ - id: 'fast_poll', - name: 'Fast Poll', - type: 'polling', - pollingIntervalMs: 500, - })).toThrow(); - }); - - it('should reject invalid trigger type', () => { - expect(() => ConnectorTriggerSchema.parse({ - id: 'test', - name: 'Test', - type: 'invalid', - })).toThrow(); - }); -}); - -describe('ConnectorSchema', () => { - const minimalConnector = { - id: 'slack', - name: 'Slack', - category: 'communication', - authentication: { type: 'apiKey' }, - }; - - it('should accept minimal connector with defaults', () => { - const result = ConnectorSchema.parse(minimalConnector); - expect(result.verified).toBe(false); - }); - - it('should accept full connector', () => { - expect(() => ConnectorSchema.parse({ - ...minimalConnector, - description: 'Slack integration', - version: '1.0.0', - icon: 'slack-icon', - baseUrl: 'https://slack.com/api', - operations: [{ id: 'send_message', name: 'Send Message', type: 'action' }], - triggers: [{ id: 'new_message', name: 'New Message', type: 'webhook' }], - rateLimit: { requestsPerSecond: 10, requestsPerMinute: 100 }, - author: 'ObjectStack', - documentation: 'https://docs.example.com', - homepage: 'https://example.com', - license: 'MIT', - tags: ['chat', 'messaging'], - verified: true, - metadata: { tier: 'premium' }, - })).not.toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => ConnectorSchema.parse({})).toThrow(); - expect(() => ConnectorSchema.parse({ id: 'test' })).toThrow(); - }); - - it('should reject invalid id format', () => { - expect(() => ConnectorSchema.parse({ - ...minimalConnector, - id: 'My-Connector', - })).toThrow(); - }); -}); - -describe('ConnectorInstanceSchema', () => { - it('should accept valid instance with defaults', () => { - const result = ConnectorInstanceSchema.parse({ - id: 'inst-123', - connectorId: 'slack', - name: 'Slack Production', - credentials: { api_key: 'encrypted-value' }, - }); - expect(result.active).toBe(true); - expect(result.testStatus).toBe('unknown'); - }); - - it('should accept full instance', () => { - expect(() => ConnectorInstanceSchema.parse({ - id: 'inst-456', - connectorId: 'slack', - name: 'Slack Dev', - description: 'Development instance', - credentials: { api_key: 'encrypted' }, - config: { workspace: 'dev' }, - active: false, - createdAt: '2024-01-01T00:00:00Z', - lastTestedAt: '2024-01-02T00:00:00Z', - testStatus: 'success', - })).not.toThrow(); - }); - - it('should reject missing credentials', () => { - expect(() => ConnectorInstanceSchema.parse({ - id: 'inst-789', - connectorId: 'slack', - name: 'Slack', - })).toThrow(); - }); - - it('should reject invalid datetime', () => { - expect(() => ConnectorInstanceSchema.parse({ - id: 'inst-789', - connectorId: 'slack', - name: 'Slack', - credentials: {}, - createdAt: 'not-a-date', - })).toThrow(); - }); -}); - -describe('Connector factory', () => { - it('should create an API key connector', () => { - const connector = Connector.apiKey({ - id: 'twilio', - name: 'Twilio', - category: 'communication', - baseUrl: 'https://api.twilio.com', - }); - expect(connector.authentication.type).toBe('apiKey'); - expect(connector.verified).toBe(false); - expect(() => ConnectorSchema.parse(connector)).not.toThrow(); - }); - - it('should create an OAuth2 connector', () => { - const connector = Connector.oauth2({ - id: 'salesforce', - name: 'Salesforce', - category: 'crm', - baseUrl: 'https://login.salesforce.com', - authUrl: 'https://login.salesforce.com/services/oauth2/authorize', - tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - scopes: ['api', 'refresh_token'], - }); - expect(connector.authentication.type).toBe('oauth2'); - expect(connector.authentication.oauth2?.scopes).toEqual(['api', 'refresh_token']); - expect(() => ConnectorSchema.parse(connector)).not.toThrow(); - }); -}); diff --git a/packages/spec/src/automation/trigger-registry.zod.ts b/packages/spec/src/automation/trigger-registry.zod.ts deleted file mode 100644 index 826ec7546d..0000000000 --- a/packages/spec/src/automation/trigger-registry.zod.ts +++ /dev/null @@ -1,630 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; - -/** - * Trigger Registry Protocol - * - * Lightweight automation triggers for simple integrations. - * Inspired by Zapier, n8n, and Workato connector architectures. - * - * ## When to use Trigger Registry vs. Integration Connector? - * - * **Use `automation/trigger-registry.zod.ts` when:** - * - Building simple automation triggers (e.g., "when Slack message received, create task") - * - No complex authentication needed (simple API keys, basic auth) - * - Lightweight, single-purpose integrations - * - Quick setup with minimal configuration - * - Webhook-based or polling triggers for automation workflows - * - * **Use `integration/connector.zod.ts` when:** - * - Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) - * - Complex OAuth2/SAML authentication required - * - Bidirectional sync with field mapping and transformations - * - Webhook management and rate limiting required - * - Full CRUD operations and data synchronization - * - * ## Use Cases - * - * 1. **Simple Automation Triggers** - * - Slack notifications on record updates - * - Twilio SMS on workflow events - * - SendGrid email templates - * - * 2. **Lightweight Operations** - * - Single-action integrations (send, notify, log) - * - No bidirectional sync required - * - Webhook receivers for incoming events - * - * 3. **Quick Integrations** - * - Payment webhooks (Stripe, PayPal) - * - Communication triggers (Twilio, SendGrid, Slack) - * - Simple API calls to third-party services - * - * @see https://zapier.com/developer/documentation/v2/ - * @see https://docs.n8n.io/integrations/creating-nodes/ - * @see ../../integration/connector.zod.ts for enterprise connectors - * - * @example - * ```typescript - * const slackNotifier: Connector = { - * id: 'slack_notify', - * name: 'Slack Notification', - * category: 'communication', - * authentication: { - * type: 'apiKey', - * fields: [{ name: 'webhook_url', label: 'Webhook URL', type: 'url' }] - * }, - * operations: [ - * { id: 'send_message', name: 'Send Message', type: 'action' } - * ] - * } - * ``` - */ - -/** - * Connector Category - */ -import { lazySchema } from '../shared/lazy-schema'; -export const ConnectorCategorySchema = lazySchema(() => z.enum([ - 'crm', // Customer Relationship Management - 'payment', // Payment processors - 'communication', // Email, SMS, Chat - 'storage', // File storage - 'analytics', // Analytics platforms - 'database', // Databases - 'marketing', // Marketing automation - 'accounting', // Accounting software - 'hr', // Human resources - 'productivity', // Productivity tools - 'ecommerce', // E-commerce platforms - 'support', // Customer support - 'devtools', // Developer tools - 'social', // Social media - 'other', // Other category -])); - -export type ConnectorCategory = z.infer; - -/** - * Authentication Type - */ -export const AuthenticationTypeSchema = lazySchema(() => z.enum([ - 'none', // No authentication - 'apiKey', // API key - 'basic', // Basic auth (username/password) - 'bearer', // Bearer token - 'oauth1', // OAuth 1.0 - 'oauth2', // OAuth 2.0 - 'custom', // Custom authentication -])); - -export type AuthenticationType = z.infer; - -/** - * Authentication Field Schema - */ -export const AuthFieldSchema = lazySchema(() => z.object({ - /** - * Field name (machine name) - */ - name: z.string() - .regex(/^[a-z_][a-z0-9_]*$/) - .describe('Field name (snake_case)'), - - /** - * Field label - */ - label: z.string().describe('Field label'), - - /** - * Field type - */ - type: z.enum(['text', 'password', 'url', 'select']) - .default('text') - .describe('Field type'), - - /** - * Field description - */ - description: z.string().optional().describe('Field description'), - - /** - * Whether field is required - */ - required: z.boolean().default(true).describe('Required field'), - - /** - * Default value - */ - default: z.string().optional().describe('Default value'), - - /** - * Options for select fields - */ - options: z.array(z.object({ - label: z.string(), - value: z.string(), - })).optional().describe('Select field options'), - - /** - * Placeholder text - */ - placeholder: z.string().optional().describe('Placeholder text'), -})); - -export type AuthField = z.infer; - -/** - * OAuth 2.0 Configuration - */ -export const OAuth2ConfigSchema = lazySchema(() => z.object({ - /** - * Authorization URL - */ - authorizationUrl: z.string().url().describe('Authorization endpoint URL'), - - /** - * Token URL - */ - tokenUrl: z.string().url().describe('Token endpoint URL'), - - /** - * Scopes to request - */ - scopes: z.array(z.string()).optional().describe('OAuth scopes'), - - /** - * Client ID field name - */ - clientIdField: z.string().default('client_id').describe('Client ID field name'), - - /** - * Client secret field name - */ - clientSecretField: z.string().default('client_secret').describe('Client secret field name'), -})); - -export type OAuth2Config = z.infer; - -/** - * Authentication Configuration - */ -export const AuthenticationSchema = lazySchema(() => z.object({ - /** - * Authentication type - */ - type: AuthenticationTypeSchema.describe('Authentication type'), - - /** - * Authentication fields - * Configuration fields needed for this auth type - */ - fields: z.array(AuthFieldSchema).optional().describe('Authentication fields'), - - /** - * OAuth 2.0 configuration (when type is oauth2) - */ - oauth2: OAuth2ConfigSchema.optional().describe('OAuth 2.0 configuration'), - - /** - * Test authentication instructions - */ - test: z.object({ - url: z.string().optional().describe('Test endpoint URL'), - method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).default('GET').describe('HTTP method'), - }).optional().describe('Authentication test configuration'), -})); - -export type Authentication = z.infer; - -/** - * Connector Operation Type - */ -export const OperationTypeSchema = lazySchema(() => z.enum([ - 'read', // Read/query data - 'write', // Create/update data - 'delete', // Delete data - 'search', // Search operation - 'trigger', // Webhook/polling trigger - 'action', // Custom action -])); - -export type OperationType = z.infer; - -/** - * Operation Parameter Schema - */ -export const OperationParameterSchema = lazySchema(() => z.object({ - /** - * Parameter name - */ - name: z.string().describe('Parameter name'), - - /** - * Parameter label - */ - label: z.string().describe('Parameter label'), - - /** - * Parameter description - */ - description: z.string().optional().describe('Parameter description'), - - /** - * Parameter type - */ - type: z.enum(['string', 'number', 'boolean', 'array', 'object', 'date', 'file']) - .describe('Parameter type'), - - /** - * Whether parameter is required - */ - required: z.boolean().default(false).describe('Required parameter'), - - /** - * Default value - */ - default: z.unknown().optional().describe('Default value'), - - /** - * Validation schema - */ - validation: z.record(z.string(), z.unknown()).optional().describe('Validation rules'), - - /** - * Dynamic options function - */ - dynamicOptions: z.string().optional().describe('Function to load dynamic options'), -})); - -export type OperationParameter = z.infer; - -/** - * Connector Operation Schema - */ -export const ConnectorOperationSchema = lazySchema(() => z.object({ - /** - * Operation identifier - */ - id: z.string() - .regex(/^[a-z_][a-z0-9_]*$/) - .describe('Operation ID (snake_case)'), - - /** - * Operation name - */ - name: z.string().describe('Operation name'), - - /** - * Operation description - */ - description: z.string().optional().describe('Operation description'), - - /** - * Operation type - */ - type: OperationTypeSchema.describe('Operation type'), - - /** - * Input parameters - */ - inputSchema: z.array(OperationParameterSchema) - .optional() - .describe('Input parameters'), - - /** - * Output schema - */ - outputSchema: z.record(z.string(), z.unknown()) - .optional() - .describe('Output schema'), - - /** - * Sample output for documentation - */ - sampleOutput: z.unknown().optional().describe('Sample output'), - - /** - * Whether operation supports pagination - */ - supportsPagination: z.boolean().default(false).describe('Supports pagination'), - - /** - * Whether operation supports filtering - */ - supportsFiltering: z.boolean().default(false).describe('Supports filtering'), -})); - -export type ConnectorOperation = z.infer; - -/** - * Connector Trigger Schema - * - * Triggers are special operations that watch for events and initiate workflows. - * - * ⚠️ NOT YET ENFORCED — declared but has no runtime consumer (#3197). No - * runtime imports this schema (or `TriggerRegistrySchema` below); in - * particular the `stream` trigger mechanism exists only here and has no - * implementation anywhere. - */ -export const ConnectorTriggerSchema = lazySchema(() => z.object({ - /** - * Trigger identifier - */ - id: z.string() - .regex(/^[a-z_][a-z0-9_]*$/) - .describe('Trigger ID (snake_case)'), - - /** - * Trigger name - */ - name: z.string().describe('Trigger name'), - - /** - * Trigger description - */ - description: z.string().optional().describe('Trigger description'), - - /** - * Trigger type - */ - type: z.enum(['webhook', 'polling', 'stream']) - .describe('Trigger mechanism'), - - /** - * Trigger configuration - */ - config: z.record(z.string(), z.unknown()) - .optional() - .describe('Trigger configuration'), - - /** - * Output schema - */ - outputSchema: z.record(z.string(), z.unknown()) - .optional() - .describe('Event payload schema'), - - /** - * Polling interval (for polling triggers) - * In milliseconds - */ - pollingIntervalMs: z.number().int().min(1000) - .optional() - .describe('Polling interval in ms'), -})); - -export type ConnectorTrigger = z.infer; - -/** - * Connector Schema - * - * Complete definition of a connector to an external system. - */ -export const ConnectorSchema = lazySchema(() => z.object({ - /** - * Connector identifier - * Must be globally unique - */ - id: z.string() - .regex(/^[a-z_][a-z0-9_]*$/) - .describe('Connector ID (snake_case)'), - - /** - * Connector name - */ - name: z.string().describe('Connector name'), - - /** - * Connector description - */ - description: z.string().optional().describe('Connector description'), - - /** - * Connector version (semver) - */ - version: z.string().optional().describe('Connector version'), - - /** - * Connector icon URL or name - */ - icon: z.string().optional().describe('Connector icon'), - - /** - * Connector category - */ - category: ConnectorCategorySchema.describe('Connector category'), - - /** - * Base URL for API calls - */ - baseUrl: z.string().url().optional().describe('API base URL'), - - /** - * Authentication configuration - */ - authentication: AuthenticationSchema.describe('Authentication config'), - - /** - * Available operations - */ - operations: z.array(ConnectorOperationSchema) - .optional() - .describe('Connector operations'), - - /** - * Available triggers - */ - triggers: z.array(ConnectorTriggerSchema) - .optional() - .describe('Connector triggers'), - - /** - * Rate limiting information - */ - rateLimit: z.object({ - requestsPerSecond: z.number().optional().describe('Max requests per second'), - requestsPerMinute: z.number().optional().describe('Max requests per minute'), - requestsPerHour: z.number().optional().describe('Max requests per hour'), - }).optional().describe('Rate limiting'), - - /** - * Connector author - */ - author: z.string().optional().describe('Connector author'), - - /** - * Documentation URL - */ - documentation: z.string().url().optional().describe('Documentation URL'), - - /** - * Homepage URL - */ - homepage: z.string().url().optional().describe('Homepage URL'), - - /** - * License - */ - license: z.string().optional().describe('License (SPDX identifier)'), - - /** - * Tags for discovery - */ - tags: z.array(z.string()).optional().describe('Connector tags'), - - /** - * Whether connector is verified/certified - */ - verified: z.boolean().default(false).describe('Verified connector'), - - /** - * Custom metadata - */ - metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata'), -})); - -export type Connector = z.infer; - -/** - * Connector Instance Schema - * - * A configured instance of a connector with credentials. - */ -export const ConnectorInstanceSchema = lazySchema(() => z.object({ - /** - * Instance ID - */ - id: z.string().describe('Instance ID'), - - /** - * Connector ID this instance uses - */ - connectorId: z.string().describe('Connector ID'), - - /** - * Instance name - */ - name: z.string().describe('Instance name'), - - /** - * Instance description - */ - description: z.string().optional().describe('Instance description'), - - /** - * Authentication credentials (encrypted) - */ - credentials: z.record(z.string(), z.unknown()).describe('Encrypted credentials'), - - /** - * Additional configuration - */ - config: z.record(z.string(), z.unknown()).optional().describe('Additional config'), - - /** - * Whether instance is active - */ - active: z.boolean().default(true).describe('Instance active status'), - - /** - * Created timestamp - */ - createdAt: z.string().datetime().optional().describe('Creation time'), - - /** - * Last tested timestamp - */ - lastTestedAt: z.string().datetime().optional().describe('Last test time'), - - /** - * Test status - */ - testStatus: z.enum(['unknown', 'success', 'failed']) - .default('unknown') - .describe('Connection test status'), -})); - -export type ConnectorInstance = z.infer; - -/** - * Helper factory for creating connectors - */ -export const Connector = { - /** - * Create a basic API key connector - */ - apiKey: (params: { - id: string; - name: string; - category: ConnectorCategory; - baseUrl: string; - }): Connector => ({ - id: params.id, - name: params.name, - category: params.category, - baseUrl: params.baseUrl, - authentication: { - type: 'apiKey', - fields: [ - { - name: 'api_key', - label: 'API Key', - type: 'password', - required: true, - }, - ], - }, - verified: false, - }), - - /** - * Create an OAuth 2.0 connector - */ - oauth2: (params: { - id: string; - name: string; - category: ConnectorCategory; - baseUrl: string; - authUrl: string; - tokenUrl: string; - scopes?: string[]; - }): Connector => ({ - id: params.id, - name: params.name, - category: params.category, - baseUrl: params.baseUrl, - authentication: { - type: 'oauth2', - oauth2: { - authorizationUrl: params.authUrl, - tokenUrl: params.tokenUrl, - clientIdField: 'client_id', - clientSecretField: 'client_secret', - scopes: params.scopes, - }, - }, - verified: false, - }), -} as const; diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 5df42899d5..86af4b308a 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -326,6 +326,25 @@ export interface ApprovalActionRow { reassign_from_name?: string; /** Display name of `reassign_to` (`sys_user.name`), when resolvable. */ reassign_to_name?: string; + /** + * Whether the actor was admitted to this action ONLY by the privileged + * admin-override path (#3424) — they held no slot in the request's + * pending-approver slate (#4466). + * + * Before this the two were indistinguishable in the audit trail: an admin + * overriding a properly-staffed slate wrote byte-for-byte the same row as the + * designated approver approving normally, and the bypassed approver's later + * `409 INVALID_STATE` was the only trace — existing only if they happened to + * try. The platform knows at decision time (it took the override branch to + * admit the call), so this was dropped information, not unavailable + * information. Consumers render the distinction; the whole point of an + * approval record is to answer "who authorized this, and were they entitled + * to?". + * + * `false` means checked and NOT an override. `undefined` means the row + * predates the column — "not recorded", which is not the same claim. + */ + via_override?: boolean; } /** Input for a decision on an approval request. */ @@ -374,6 +393,12 @@ export interface ApprovalRecallResult { * "did not pass" semantics. */ resumed?: boolean; + /** + * Why the run was not resumed, when `resumed` is false but the recall itself + * succeeded. A recall abandons the request, so a lost run does not fail the + * call — but it must not read as a clean resume either (#4420). + */ + resumeError?: string; } /** Input for sending a pending request back for revision (ADR-0044). */ @@ -391,6 +416,12 @@ export interface ApprovalSendBackResult { runId?: string | null; /** True when the owning flow run was resumed (down `revise`, or `reject` on auto-reject). */ resumed?: boolean; + /** + * Why the run was not resumed, on the paths that tolerate it (a concurrent + * duplicate resume). A resume failure that strands the run throws instead — + * see `RESUME_TARGET_LOST` / `RESUME_FAILED` (#4420). + */ + resumeError?: string; /** * True when the send-back exceeded the node's `maxRevisions` budget and the * request was auto-rejected instead (resumed down `reject` with @@ -413,6 +444,12 @@ export interface ApprovalResubmitResult { runId?: string | null; /** True when the owning flow run was resumed (it re-enters the approval node and opens round N+1). */ resumed?: boolean; + /** + * Why the run was not resumed, on the paths that tolerate it (a concurrent + * duplicate resume). A resume failure that strands the run throws instead — + * see `RESUME_TARGET_LOST` / `RESUME_FAILED` (#4420). + */ + resumeError?: string; } /** Result of a decision that resumes the owning flow when finalised. */ @@ -423,8 +460,22 @@ export interface ApprovalDecisionResult { decision: 'approve' | 'reject'; /** The suspended flow run that was (or will be) resumed, if any. */ runId?: string | null; - /** True when the owning flow run was resumed as a result of this decision. */ + /** + * True when the owning flow run was resumed as a result of this decision. + * + * A decision that finalises a flow-bound request and CANNOT resume its run + * throws rather than returning `resumed: false` — a recorded decision whose + * flow never advances is the zombie half-state of #4420. `false` here means + * either there was nothing to resume (no run, not finalised, no automation + * attached) or a benign duplicate, in which case see {@link resumeError}. + */ resumed?: boolean; + /** + * Why the run was not resumed, on the one path that tolerates it: a + * concurrent duplicate resume (`RESUME_IN_PROGRESS`) — the other caller is + * already advancing the run, so this decision is complete and correct. + */ + resumeError?: string; } /** diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 828f406669..aa5c5ae343 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -17,6 +17,7 @@ import type { FlowParsed } from '../automation/flow.zod'; import type { ExecutionLog, FlowRunSummary } from '../automation/execution.zod'; import type { ActionDescriptor } from '../automation/node-executor.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; +import type { ConversionNotice, ConversionConflictNotice } from '../conversions/types'; /** * Context passed to a flow/script execution @@ -189,11 +190,35 @@ export interface AutomationResult { * flow engine reserves for itself (a `$…` name, or one carrying a `.$` * segment: `$runId`, `.$mapItemDone`, …). A transport maps it to * **400**. + * - `'RUN_NOT_FOUND'` — no suspension exists for the run id, in the hot + * cache or the durable store. The run is unresumable *for good*: it + * already resumed, was cancelled, or paused in a process whose state was + * never persisted (#4420). A transport maps it to **404**. Callers that + * persist a decision before resuming (approvals) must treat this as a + * hard failure, not a no-op. + * - `'STORE_UNAVAILABLE'` — the durable store could not be read, so + * whether a suspension exists is UNKNOWN. Distinct from + * `'RUN_NOT_FOUND'` on purpose: a transient store outage must not be + * mistaken for a dead run. A transport maps it to **503**; the same + * resume is expected to succeed once the store recovers. + * - `'RESUME_IN_PROGRESS'` — a concurrent resume of this run is already + * running; this duplicate was refused so side effects cannot run twice. + * A transport maps it to **409**. The other resume is doing the work, + * so callers should treat it as benign. + * - `'INVALID_SCREEN_INPUT'` — the run is parked on a `screen` node and + * the submitted bag violates that screen's declared field contract: a + * `required` field the caller WAS asked for is missing, or a key the + * screen never declared was sent (#4477). A transport maps it to + * **400**. Distinct from `'INVALID_SIGNAL'`, which is about the + * engine's own `$` variable namespace rather than the author's field + * declarations. `visibleWhen` is evaluated against the submitted values + * first, so a HIDDEN field's `required` never fires — enforcing it would + * dead-end the run at a field the user was never shown (#3528). * - * Both refuse before consuming the suspension: the run stays parked and the - * legitimate continuation still lands. + * All of these refuse before consuming the suspension: the run stays parked + * and the legitimate continuation still lands. */ - code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL'; + code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT'; /** * Lifecycle status. `'paused'` means the run suspended at a node (e.g. * an Approval node awaiting a human decision, ADR-0019) and can be @@ -325,6 +350,38 @@ export interface IAutomationService { */ registerFlow?(name: string, definition: unknown): void; + /** + * Canonicalize a flow definition WITHOUT registering it (#4454). + * + * The same ADR-0087 conversion policy {@link registerFlow} applies, exposed + * for a caller that needs a flow's canonical shape but must not arm it — + * `os migrate meta --stored` rewriting stored `sys_metadata` rows is the + * reason this is on the contract rather than only on the implementation. + * + * Only an implementation holding the live executor registry can offer this: + * flow-node conversions carry ADR-0078's open-namespace conflict guard, and + * deciding a rename from a clobber requires knowing which node types are + * actually owned here. Hence optional — a caller falls back to leaving flow + * rows alone rather than guessing. + * + * @param name - Flow name (snake_case), used for diagnostics + * @param definition - The stored/authored flow body + * @returns `parsed` (execution shape — schema defaults materialized) and + * `storable` (persistence shape — conversions plus the schema's + * `condition` envelopes, deliberately WITHOUT schema defaults, so a + * written-back row is not frozen on today's default values), plus the + * conversions applied and any rewrite the guard refused. + * @throws when the definition cannot be canonicalized at all (a strict-schema + * violation, a malformed control-flow region) — such a flow cannot be + * registered either, so a caller reports it rather than persisting a guess. + */ + canonicalizeStoredFlow?(name: string, definition: unknown): { + parsed: FlowParsed; + storable: unknown; + notices: ConversionNotice[]; + conflicts: ConversionConflictNotice[]; + }; + /** * Unregister a flow by name * @param name - Flow name (snake_case) diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts index 0c4049f0c2..340358a255 100644 --- a/packages/spec/src/contracts/core-service-contracts.test.ts +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -29,10 +29,11 @@ describe('CoreServiceName → contract map (#4127)', () => { // The map's keys are checked against the enum at compile time below; // this asserts the same thing at runtime so a rename shows up as a // failing test and not only as a type error in an unrelated package. + // ('workflow' left both lists with its slot, #4451 v17.) const mapped: Array = [ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', - 'i18n', 'workflow', + 'i18n', ]; const slots = new Set(CoreServiceName.options); for (const key of mapped) { @@ -44,7 +45,7 @@ describe('CoreServiceName → contract map (#4127)', () => { const mapped = new Set([ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', - 'i18n', 'workflow', + 'i18n', ]); const unmapped = CoreServiceName.options.filter((s) => !mapped.has(s)); // `ui` has a slot and a serving domain but no `IUiService` — mapping it diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts index f0d9473028..6afaf338bf 100644 --- a/packages/spec/src/contracts/core-service-contracts.ts +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -37,7 +37,6 @@ import type { IJobService } from './job-service'; import type { INotificationService } from './notification-service'; import type { IAIService } from './ai-service'; import type { II18nService } from './i18n-service'; -import type { IWorkflowService } from './workflow-service'; import type { ISecurityService } from './security-service'; import type { IShareLinkService } from './share-link-service'; import type { IHttpServer } from './http-server'; @@ -79,7 +78,8 @@ export interface CoreServiceContracts { ai: IAIService; /** `service-i18n`, or the `app-plugin` in-memory fallback (#4143). */ i18n: II18nService; - workflow: IWorkflowService; + // `workflow: IWorkflowService` removed with the slot (#4451, v17) — no + // implementation ever existed, so there was no evidenced binding here. } /** diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index 571d57704d..618ede8e04 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -68,6 +68,20 @@ export interface IDataEngine { * supported; when both are given, `options.context` wins. */ find(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; + /** + * Read the ONE record the query selects, or `null`. + * + * The query MUST say which record it wants: a `where` (or a `search` that + * expands to one), or an `orderBy` meaning "the FIRST record in this order". + * A query with neither is REJECTED (#4419) — `findOne` reads a single row, so + * an empty predicate does not return nothing, it returns the object's first + * row: a real, plausible-looking record unrelated to the request, which no + * caller's `if (!row)` can catch. When any row genuinely will do, that is + * `find(objectName, { limit: 1 })`, which says so at the call site. + * + * No ordering is imposed when the caller supplies none: `findOne` promises + * *a* matching record, never a position in a sequence (#4363). + */ findOne(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise; update(objectName: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise; diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 12d6d355a6..be0b3d0187 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -31,7 +31,9 @@ export * from './job-service.js'; export * from './ai-service.js'; export * from './llm-adapter.js'; export * from './i18n-service.js'; -export * from './workflow-service.js'; +// './workflow-service.js' removed (#4451, v17): IWorkflowService had no +// implementation and no `getService('workflow')` call site anywhere +// (ADR-0115 Evidence 5); the slot retired with it. // CoreServiceName → contract map (#4127). Lets a slot lookup return the slot's // contract instead of `any`, so a call outside it is a compile error. diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 1af6394adb..62fc8051f8 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -37,12 +37,11 @@ import type { MetadataQuery, MetadataQueryResult, MetadataValidationResult, MetadataBulkResult, MetadataDependency } from '../kernel/metadata-plugin.zod'; // The PERSISTENCE-side watch event (`add`/`added`/`changed`/`deleted`/…, path + -// file stats) — what `MetadataManager.subscribe` actually relays. NOT the -// near-namesake in `../kernel/metadata-loader.zod`: spec carries TWO types -// named `MetadataWatchEvent` with different shapes (reported on #4251; merging -// them is its own change), and `MetadataManager implements IMetadataService` -// rejected the first draft of this import — which is exactly the check doing -// its job. +// file stats) — what `MetadataManager.subscribe` relays, as opposed to the +// registration-level events `watch` forwards (`MetadataWatchCallback` below). +// Spec used to carry a second, differently-shaped `MetadataWatchEvent` on +// `@objectstack/spec/kernel`; it had no consumers and was removed in #4411, so +// this is now the only type by that name. import type { MetadataWatchEvent } from '../system/metadata-persistence.zod'; import type { Action } from '../ui/action.zod'; import type { MetadataOverlay } from '../kernel/metadata-customization.zod'; diff --git a/packages/spec/src/contracts/workflow-service.test.ts b/packages/spec/src/contracts/workflow-service.test.ts deleted file mode 100644 index 9050be560b..0000000000 --- a/packages/spec/src/contracts/workflow-service.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { IWorkflowService, WorkflowTransition, WorkflowTransitionResult, WorkflowStatus } from './workflow-service'; - -describe('Workflow Service Contract', () => { - it('should allow a minimal IWorkflowService implementation with required methods', () => { - const service: IWorkflowService = { - transition: async (_transition) => ({ success: true, currentState: 'approved' }), - getStatus: async (_object, _recordId) => ({ - recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [], - }), - }; - - expect(typeof service.transition).toBe('function'); - expect(typeof service.getStatus).toBe('function'); - }); - - it('should allow a full implementation with optional methods', () => { - const service: IWorkflowService = { - transition: async () => ({ success: true }), - getStatus: async () => ({ - recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [], - }), - getHistory: async () => [], - }; - - expect(service.getHistory).toBeDefined(); - }); - - it('should transition a record to a new state', async () => { - const states = new Map(); - states.set('order:ord-1', 'draft'); - - const allowedTransitions: Record = { - draft: ['submitted'], - submitted: ['approved', 'rejected'], - approved: ['completed'], - }; - - const service: IWorkflowService = { - transition: async (t): Promise => { - const key = `${t.object}:${t.recordId}`; - const current = states.get(key); - if (!current) return { success: false, error: 'Record not found' }; - - const allowed = allowedTransitions[current] ?? []; - if (!allowed.includes(t.targetState)) { - return { success: false, error: `Cannot transition from ${current} to ${t.targetState}` }; - } - - states.set(key, t.targetState); - return { success: true, currentState: t.targetState }; - }, - getStatus: async (object, recordId) => { - const key = `${object}:${recordId}`; - const currentState = states.get(key) ?? 'unknown'; - return { - recordId, object, currentState, - availableTransitions: allowedTransitions[currentState] ?? [], - }; - }, - }; - - const result = await service.transition({ - recordId: 'ord-1', - object: 'order', - targetState: 'submitted', - comment: 'Ready for review', - }); - - expect(result.success).toBe(true); - expect(result.currentState).toBe('submitted'); - - const status = await service.getStatus('order', 'ord-1'); - expect(status.currentState).toBe('submitted'); - expect(status.availableTransitions).toContain('approved'); - }); - - it('should reject invalid transitions', async () => { - const service: IWorkflowService = { - transition: async (t): Promise => ({ - success: false, - error: `Cannot transition to ${t.targetState}`, - }), - getStatus: async () => ({ - recordId: '1', object: 'order', currentState: 'draft', - availableTransitions: ['submitted'], - }), - }; - - const result = await service.transition({ - recordId: '1', - object: 'order', - targetState: 'completed', - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('completed'); - }); - - it('should return transition history', async () => { - const service: IWorkflowService = { - transition: async () => ({ success: true }), - getStatus: async () => ({ - recordId: '1', object: 'order', currentState: 'approved', - availableTransitions: ['completed'], - }), - getHistory: async () => [ - { fromState: 'draft', toState: 'submitted', userId: 'u1', timestamp: '2025-01-01T00:00:00Z' }, - { fromState: 'submitted', toState: 'approved', userId: 'u2', comment: 'LGTM', timestamp: '2025-01-02T00:00:00Z' }, - ], - }; - - const history = await service.getHistory!('order', 'ord-1'); - expect(history).toHaveLength(2); - expect(history[0].fromState).toBe('draft'); - expect(history[1].comment).toBe('LGTM'); - }); - - it('should get workflow status with available transitions', async () => { - const service: IWorkflowService = { - transition: async () => ({ success: true }), - getStatus: async (_object, _recordId): Promise => ({ - recordId: 'ord-1', - object: 'order', - currentState: 'submitted', - availableTransitions: ['approved', 'rejected'], - }), - }; - - const status = await service.getStatus('order', 'ord-1'); - expect(status.currentState).toBe('submitted'); - expect(status.availableTransitions).toEqual(['approved', 'rejected']); - }); -}); diff --git a/packages/spec/src/contracts/workflow-service.ts b/packages/spec/src/contracts/workflow-service.ts deleted file mode 100644 index d5ef04e5e3..0000000000 --- a/packages/spec/src/contracts/workflow-service.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * IWorkflowService - Workflow State Machine Service Contract - * - * Defines the interface for workflow state management and approval processes - * in ObjectStack. Concrete implementations (state machine engines, BPMN, etc.) - * should implement this interface. - * - * Follows Dependency Inversion Principle - plugins depend on this interface, - * not on concrete workflow engine implementations. - * - * Aligned with CoreServiceName 'workflow' in core-services.zod.ts. - */ - -/** - * A state transition request - */ -export interface WorkflowTransition { - /** Record identifier */ - recordId: string; - /** Object name the record belongs to */ - object: string; - /** Target state to transition to */ - targetState: string; - /** Optional comment for the transition */ - comment?: string; - /** User performing the transition */ - userId?: string; -} - -/** - * Result of a transition attempt - */ -export interface WorkflowTransitionResult { - /** Whether the transition succeeded */ - success: boolean; - /** The new current state (if success) */ - currentState?: string; - /** Error or rejection reason (if failure) */ - error?: string; -} - -/** - * Status of a workflow instance - */ -export interface WorkflowStatus { - /** Record identifier */ - recordId: string; - /** Object name */ - object: string; - /** Current state */ - currentState: string; - /** Available transitions from the current state */ - availableTransitions: string[]; -} - -export interface IWorkflowService { - /** - * Transition a record to a new workflow state - * @param transition - Transition request details - * @returns Transition result - */ - transition(transition: WorkflowTransition): Promise; - - /** - * Get the current workflow status of a record - * @param object - Object name - * @param recordId - Record identifier - * @returns Current workflow status with available transitions - */ - getStatus(object: string, recordId: string): Promise; - - /** - * Get transition history for a record - * @param object - Object name - * @param recordId - Record identifier - * @returns Array of historical transitions - */ - getHistory?(object: string, recordId: string): Promise>; -} diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index a8541c30c5..3d67017a6c 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { FlowSchema } from '../automation/flow.zod.js'; +import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; @@ -289,6 +290,114 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + describe('flow-node-script-branch-keys-removed (#4343)', () => { + /** One `script` node in a flow shaped the way the conversion walks it. */ + const scriptFlow = (config: Record) => ({ + flows: [ + { + name: 'task_lifecycle', + label: 'Task lifecycle', + type: 'autolaunched', + edges: [], + nodes: [ + { id: 'n1', type: 'start', label: 'Start' }, + { id: 's', type: 'script', label: 'Script', config }, + ], + }, + ], + }); + const cfgOf = (stack: Record) => (stack.flows as any[])[0].nodes[1].config; + // Retired from the load path (the keys misdescribed themselves), so the + // default `applyConversions` skips it — only `os migrate meta` replays it. + const convert = (stack: Record) => collectConversionNotices(stack, { includeRetired: true }); + + it('moves a shorthand `actionType` into `function` — that is what it named', () => { + const { stack, notices } = convert(scriptFlow({ actionType: 'score_lead', outputVariable: 'score' })); + expect(cfgOf(stack)).toEqual({ function: 'score_lead', outputVariable: 'score' }); + expect(notices).toHaveLength(1); + expect(notices[0]!.to).toBe('config.function'); + }); + + it('drops a shorthand `actionType` instead of moving it when `function` already won', () => { + const { stack, notices } = convert(scriptFlow({ actionType: 'stale_name', function: 'score_lead' })); + expect(cfgOf(stack)).toEqual({ function: 'score_lead' }); + expect(notices).toHaveLength(1); + }); + + it('drops the built-in ids and the bare marker — neither was ever a function name', () => { + for (const actionType of ['email', 'slack', 'invoke_function']) { + const { stack, notices } = convert(scriptFlow({ actionType, function: 'score_lead' })); + expect(cfgOf(stack), actionType).toEqual({ function: 'score_lead' }); + expect(notices, actionType).toHaveLength(1); + expect(notices[0]!.to, actionType).toMatch(/removed/); + } + }); + + it('drops the stub payload keys — nothing ever read them, so there is nothing to preserve', () => { + const { stack, notices } = convert(scriptFlow({ + actionType: 'email', + template: 'task_done', + recipients: ['{record.owner}'], + variables: { taskName: '{record.name}' }, + })); + expect(cfgOf(stack)).toEqual({}); + expect(notices).toHaveLength(4); + }); + + it('drops an inline `script` body the runtime never executed', () => { + const { stack, notices } = convert(scriptFlow({ script: 'return { ok: true };' })); + expect(cfgOf(stack)).toEqual({}); + expect(notices).toHaveLength(1); + }); + + it('leaves an already-converged node untouched', () => { + const { stack, notices } = convert(scriptFlow({ function: 'score_lead', inputs: { id: '{record.id}' } })); + expect(cfgOf(stack)).toEqual({ function: 'score_lead', inputs: { id: '{record.id}' } }); + expect(notices).toHaveLength(0); + }); + + it('leaves a non-script node carrying the same key names alone', () => { + // Unlike the wait retirement, these tombstones live on the script config + // contract — no other node type is parsed against it, so a `template` key + // elsewhere is that node's own business. + const stack0 = { + flows: [{ + name: 'f', + nodes: [{ id: 'n', type: 'notify', config: { template: 'x', recipients: ['a'] } }], + }], + }; + const { stack, notices } = convert(stack0); + expect(stack).toEqual(stack0); + expect(notices).toHaveLength(0); + }); + + it('tombstones every retired key so a source that skipped conversion is rejected, not stripped', () => { + // NOTE the channel: unlike `waitEventConfig`, a node's `config` is + // `z.record(z.unknown())` on `FlowNodeSchema`, so `FlowSchema.parse` does + // NOT reach these tombstones — they answer whoever AUTHORS the key (`tsc` + // types it `never`; this parse raises the prescription). A stored flow is + // reached by the other half: `registerFlow` replays this conversion even + // though it is retired (#3903), and the execute-time parse then refuses + // what is left over for naming no callable. + for (const bad of [ + { actionType: 'email' }, + { template: 't' }, + { recipients: ['a'] }, + { variables: { x: 1 } }, + { script: 'return 1;' }, + ]) { + const key = Object.keys(bad)[0]!; + expect( + () => ScriptConfigSchema.parse({ function: 'score_lead', ...bad }), + `${key} must be rejected`, + ).toThrow(/4343/); + } + // The flow-level parse is deliberately blind here — pinned so the note + // above stays true if `FlowNodeSchema.config` is ever tightened. + expect(() => FlowSchema.parse((scriptFlow({ actionType: 'email' }).flows as any[])[0])).not.toThrow(); + }); + }); + describe('flow-node-wait-event-config-lift (PD #12 retirement, #4045)', () => { /** * One `wait` node in a flow that `FlowSchema` can actually parse — `label` is diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 70402f7e30..80b3091129 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1510,6 +1510,10 @@ const flowNodeConnectorConfigLift: MetadataConversion = { * `connectorConfig.input` (singular) is a *different, canonical* surface and is * deliberately not touched here. Both are pure key renames with unchanged * values. **Live window**; retires at 18. + * + * The fixture below carried `actionType: 'invoke_function'` through both sides + * until #4343 retired that key — an end state protocol 17 no longer reaches, so + * it is gone from both. The rename itself is untouched. */ const flowNodeScriptConfigAliases: MetadataConversion = { id: 'flow-node-script-config-aliases', @@ -1538,7 +1542,6 @@ const flowNodeScriptConfigAliases: MetadataConversion = { id: 'n2', type: 'script', config: { - actionType: 'invoke_function', functionName: 'score_lead', input: { leadId: '{record.id}' }, outputVariable: 'score', @@ -1558,7 +1561,6 @@ const flowNodeScriptConfigAliases: MetadataConversion = { id: 'n2', type: 'script', config: { - actionType: 'invoke_function', function: 'score_lead', inputs: { leadId: '{record.id}' }, outputVariable: 'score', @@ -2228,6 +2230,222 @@ const flowNodeWaitTimeoutKeysRemoved: MetadataConversion = { }, }; +/** + * `datasource.readReplicas` — replica connections nothing ever opened (#4468). + * + * A lossless delete, and an unusually clear one: read/write splitting does not + * exist anywhere in the platform. `ConnectableDatasource` and + * `DatasourceConnectionSpec` carry no replicas field, the driver factory never + * reads the key, and no query path distinguishes a read from a write — so every + * statement always went to the primary regardless of what was declared here. + * + * Retired from the load path like every other key retired for *lying* rather + * than for being renamed. The distinction the registry draws (see + * `flow-node-wait-timeout-keys-removed`): a merely renamed key keeps a load + * window, because punishing an author for a spelling nobody warned them about + * is pointless. A key that misdescribed itself does not — silently absorbing it + * would let the author keep believing they had configured replica reads. + * + * Worth recording *why* this needed a conversion at all rather than passing + * unnoticed: #4410 had just taught the schema to validate each entry against the + * declared driver's config contract. Sources written between #4410 and here + * carry replica blocks that were *checked* — precise host names, correct port + * types, no typos — which is exactly the shape an author trusts most. The + * notice is what tells them the well-formed thing they wrote was never wired to + * anything. + */ +const datasourceReadReplicasRemoved: MetadataConversion = { + id: 'datasource-read-replicas-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'datasource.readReplicas', + summary: "datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", + apply(stack, emit) { + return mapCollection(stack, 'datasources', (ds, path) => stripKeys(ds, ['readReplicas'], emit, path)); + }, + fixture: { + before: { + datasources: [{ + name: 'warehouse', + driver: 'postgres', + config: { host: 'primary.internal', port: 5432, database: 'analytics' }, + readReplicas: [ + { host: 'replica-a.internal', port: 5432, database: 'analytics' }, + { host: 'replica-b.internal', port: 5432, database: 'analytics' }, + ], + }], + }, + // One notice per datasource, not per replica: the key is what was removed. + after: { + datasources: [{ + name: 'warehouse', + driver: 'postgres', + config: { host: 'primary.internal', port: 5432, database: 'analytics' }, + }], + }, + expectedNotices: 1, + }, +}; + +/** + * `script` node config — the four retired dispatch branches (protocol 17, #4343). + * + * A `script` node had four ways to name what it ran and only one of them ran + * anything. `actionType: 'email' | 'slack'` were logger-backed stubs: they wrote + * a line and reported success, and `template` / `recipients` / `variables` fed a + * message no channel ever sent — under any configuration, with or without the + * messaging service installed. Inline `config.script` was recognized and never + * executed (the built-in runtime has no server-side JS sandbox), so the node + * warned and no-op'd. Every remaining `actionType` value was shorthand for a + * registered-function name — a second spelling of `config.function` — and the + * `invoke_function` marker named nothing on its own. + * + * So the node converges on its one real path (call the function named by + * `config.function`), and the five keys leave the surface. This is what let the + * contract be parsed at execute time at all: while the legal key set depended on + * `actionType`, a flat parse would either reject valid shapes or wave everything + * through — see the module header of `automation/schemaless-node-config.zod.ts`. + * + * **Retired from the load path**, like every other key retired for lying rather + * than for being renamed (see `flow-node-wait-timeout-keys-removed` for the + * distinction the registry draws): silently absorbing `actionType: 'email'` + * would let an author keep believing the flow sends mail. + * + * A **shorthand `actionType` moves into `function`** rather than being dropped, + * because that is what it meant (#1870) — the same reasoning that moves + * `timeoutMs` into `timerDuration`. It moves only when `function` is not already + * set: with both present the executor always took `function`, so the shorthand + * was already dead metadata. The built-in ids and the `invoke_function` marker + * are never function names, so they are dropped, not moved. + * + * The other four keys are dropped outright: no reader ever consumed them, so + * there is no value to preserve. Rebuilding the intent is an authoring decision + * the tombstones prescribe per branch (`notify` for mail, a `connector_action` + * with the Slack connector — or `http` to a webhook — for Slack, a registered + * function for an inline body), not something a mechanical rewrite can guess. + * + * Ordering note: this runs AFTER `flow-node-script-config-aliases`, so the + * `functionName` → `function` rename has already happened when the shorthand + * rule asks whether `function` is set. + */ +const SCRIPT_RETIRED_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']); +const SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER = 'invoke_function'; + +function removeScriptBranchKeys(stack: Dict, emit: Emit): Dict { + return mapFlowNodes(stack, (node, path) => { + // Filtered to `script`, unlike the wait retirement: these tombstones live on + // the script config contract, which no other node type is parsed against. + if (node.type !== 'script') return node; + const cfg = node.config; + if (!isDict(cfg)) return node; + + const next: Dict = { ...cfg }; + let changed = false; + + if (next.actionType != null) { + const actionType = typeof next.actionType === 'string' ? next.actionType.trim() : ''; + const hasFunction = typeof next.function === 'string' && next.function.trim() !== ''; + const isShorthand = + actionType !== '' + && actionType !== SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER + && !SCRIPT_RETIRED_BUILTIN_ACTION_TYPES.has(actionType); + + if (isShorthand && !hasFunction) { + next.function = actionType; + emit({ from: 'config.actionType', to: 'config.function', path: `${path}.config.function` }); + } else if (isShorthand) { + emit({ from: 'config.actionType', to: '(removed — `config.function` already named the callable)', path: `${path}.config` }); + } else { + emit({ from: 'config.actionType', to: '(removed — logger-backed stub or bare marker; nothing was delivered)', path: `${path}.config` }); + } + delete next.actionType; + changed = true; + } + + for (const key of ['template', 'recipients', 'variables'] as const) { + if (next[key] == null) continue; + emit({ from: `config.${key}`, to: '(removed — fed a side effect that never delivered)', path: `${path}.config` }); + delete next[key]; + changed = true; + } + + if (next.script != null) { + emit({ from: 'config.script', to: '(removed — inline JS was never executed)', path: `${path}.config` }); + delete next.script; + changed = true; + } + + return changed ? { ...node, config: next } : node; + }); +} + +const flowNodeScriptBranchKeysRemoved: MetadataConversion = { + id: 'flow-node-script-branch-keys-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: + 'flow.node.script.config.actionType / flow.node.script.config.template / ' + + 'flow.node.script.config.recipients / flow.node.script.config.variables / ' + + 'flow.node.script.config.script', + summary: + "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — " + + "'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / " + + "'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", + apply(stack, emit) { + return removeScriptBranchKeys(stack, emit); + }, + fixture: { + before: { + flows: [ + { + name: 'task_lifecycle', + nodes: [ + { id: 'n1', type: 'start' }, + // The logger-backed stub in full: nothing here was ever delivered. + { + id: 'n2', + type: 'script', + config: { + actionType: 'email', + template: 'task_done', + recipients: ['{record.owner}'], + variables: { taskName: '{record.name}' }, + }, + }, + // Shorthand for a registered function — the one value that MOVES. + { id: 'n3', type: 'script', config: { actionType: 'score_lead', outputVariable: 'score' } }, + // Inline body: recognized, never executed. Dropped; the node is left + // naming no callable, which the execute-time parse now says out loud. + { id: 'n4', type: 'script', config: { script: 'return { ok: true };' } }, + // The marker alongside the canonical key: marker dropped, key kept. + { id: 'n5', type: 'script', config: { actionType: 'invoke_function', function: 'score_lead' } }, + // Already converged — left byte-identical. + { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'task_lifecycle', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'script', config: {} }, + { id: 'n3', type: 'script', config: { outputVariable: 'score', function: 'score_lead' } }, + { id: 'n4', type: 'script', config: {} }, + { id: 'n5', type: 'script', config: { function: 'score_lead' } }, + { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, + ], + }, + ], + }, + // n2: actionType + template + recipients + variables. n3: the move. + // n4: script. n5: the marker. n6: nothing. + expectedNotices: 7, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -2257,6 +2475,10 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect(() => DatasourceSchema.parse({ name: 'valid_datasource_name', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).not.toThrow(); expect(() => DatasourceSchema.parse({ name: 'InvalidDatasource', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); expect(() => DatasourceSchema.parse({ name: 'invalid-datasource', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); }); @@ -210,7 +210,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'test_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, }); expect(datasource.active).toBe(true); @@ -278,13 +278,29 @@ describe('DatasourceSchema', () => { name: 'mongo_db', driver: 'mongo', config: { - connectionString: 'mongodb://localhost:27017/mydb', + url: 'mongodb://localhost:27017/mydb', }, }); expect(datasource.driver).toBe('mongo'); }); + // This fixture used to spell the URI `connectionString`, a key the mongo + // builder never read — so the datasource it described would have connected to + // mongodb://localhost:27017 with no database, and the test asserting it was + // "accepted" was asserting the silence #4410 removed. + it('rejects a mongo config that spells the URI `connectionString`', () => { + const result = DatasourceSchema.safeParse({ + name: 'mongo_db', + driver: 'mongo', + config: { connectionString: 'mongodb://localhost:27017/mydb' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['config']); + expect(result.error!.issues[0]!.message).toContain('`connectionString` → `url`'); + }); + it('should accept Redis datasource', () => { const datasource = DatasourceSchema.parse({ name: 'redis_cache', @@ -341,7 +357,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'disabled_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, active: false, }); @@ -352,7 +368,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'custom_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, capabilities: { queryWindowFunctions: false, querySubqueries: false, @@ -386,26 +402,45 @@ describe('DatasourceSchema', () => { host: 'localhost', port: 5432, database: 'mydb', - pool: { - min: 2, - max: 10, - idleTimeoutMillis: 30000, - }, - ssl: { - rejectUnauthorized: false, - ca: 'certificate_content', - }, + ssl: true, + }, + pool: { + min: 2, + max: 10, + idleTimeoutMillis: 30000, + }, + ssl: { + enabled: true, + rejectUnauthorized: false, + ca: 'certificate_content', }, }); - expect(datasource.config.pool).toBeDefined(); - expect(datasource.config.ssl).toBeDefined(); + expect(datasource.pool).toBeDefined(); + expect(datasource.config.ssl).toBe(true); + // Certificates belong to the datasource-level block, which the factory now + // carries down to the client (#4410). Inside `config`, `ssl` is on/off. + expect(datasource.ssl?.ca).toBe('certificate_content'); + }); + + // The fixture above used to nest `pool` INSIDE `config`, where no driver + // reads it — so it asserted a pooled datasource that was running on the + // factory's hardcoded defaults. The rejection now carries the relocation. + it('rejects pool sizing nested inside `config`', () => { + const result = DatasourceSchema.safeParse({ + name: 'complex_db', + driver: 'postgres', + config: { database: 'mydb', pool: { min: 2, max: 10 } }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`pool: { max: … }`'); }); it('should reject datasource without required fields', () => { expect(() => DatasourceSchema.parse({ driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); expect(() => DatasourceSchema.parse({ @@ -429,7 +464,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'secure_db', driver: 'postgres', - config: { host: 'db.example.com', port: 5432 }, + config: { host: 'db.example.com', port: 5432, database: 'mydb' }, ssl: { enabled: true, rejectUnauthorized: true, @@ -445,7 +480,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'mtls_db', driver: 'postgres', - config: { host: 'db.secure.com' }, + config: { host: 'db.secure.com', database: 'mydb' }, ssl: { enabled: true, ca: '/certs/ca.pem', @@ -461,7 +496,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'ssl_db', driver: 'mysql', - config: {}, + config: { database: 'mydb' }, ssl: { enabled: true }, }); expect(result.ssl?.rejectUnauthorized).toBe(true); @@ -471,7 +506,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'local_db', driver: 'sqlite', - config: { path: './data.db' }, + config: { filename: './data.db' }, }); expect(result.ssl).toBeUndefined(); }); @@ -482,7 +517,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const ds = DatasourceSchema.parse({ name: 'default', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, }); expect(ds.schemaMode).toBe('managed'); expect(ds.external).toBeUndefined(); @@ -502,7 +537,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const ds = DatasourceSchema.parse({ name: 'warehouse', driver: 'postgres', - config: { connectionString: 'postgres://...' }, + config: { url: 'postgres://user@warehouse.internal/analytics' }, schemaMode: 'external', external: { label: 'Analytics Warehouse' }, }); @@ -518,7 +553,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'warehouse', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'external', }); expect(result.success).toBe(false); @@ -531,7 +566,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'default', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'managed', external: { allowWrites: true }, }); @@ -545,7 +580,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'warehouse', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'validate-only', }); expect(result.success).toBe(false); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index f21e5c4018..a17ddf5572 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -9,28 +9,35 @@ import { z } from 'zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { validateDriverConfig } from './driver/config-registry.zod'; /* - * ── Unknown-key strictness (#4001 data step) ──────────────────────────────── + * ── Unknown-key strictness (#4001 data step, closed out by #4410) ─────────── * * Every AUTHORING shape in this module is `.strict()`. `datasource` is a * registered metadata type (BUILTIN_METADATA_TYPE_SCHEMAS), so one shape backs * `defineDatasource()`, `defineStack({ datasources })`, the * `/api/v1/meta/datasource` endpoint, and the Setup → Datasources form. * - * TWO ESCAPE HATCHES STAY OPEN, and must: - * - `config` is per-driver by construction (a sqlite `filename` and a - * postgres `host`/`port` share no shape), so it stays `z.record`. - * NOTHING VALIDATES INSIDE IT TODAY — see {@link belongsInConfig}, which - * used to claim otherwise. Tracked as #4410. - * - `readReplicas` carries the same per-driver config objects. + * `config` stays `z.record` HERE, because it is per-driver by construction: a + * sqlite `filename` and a postgres `host`/`port` share no shape. What it no + * longer is, is unchecked. Since #4410 the refinement on + * {@link DatasourceSchema} parses it against the contract for the declared + * driver (`data/driver/config-registry.zod.ts`), so the openness at this level + * is a shape this level cannot express — not the absence of one. * - * That openness is exactly why the TOP level had to close. Before this, a + * That openness is exactly why the TOP level had to close first. Before #4001, a * connection key written one level too high — `host` next to `driver` instead * of inside `config` — was stripped in silence, and the datasource then * connected on driver defaults (localhost, default port) rather than failing. * A misplaced `password` is the same bug wearing a worse hat, which is why it * is prescribed toward `external.credentialsRef` rather than merely relocated. + * + * A driver the platform ships no contract for (a plugin's + * `com.vendor.snowflake`) keeps an unvalidated `config`. That is the honest + * boundary, not a leftover hole — see the registry's own note on why inventing + * a verdict against a shape we do not have would be worse than the silence. */ /** Keys {@link DriverDefinitionSchema} declares (drift-guarded by datasource.test.ts). */ @@ -47,7 +54,7 @@ const EXTERNAL_VALIDATION_KEYS = ['onMismatch', 'checkOnBoot', 'checkIntervalMs' /** Keys {@link DatasourceSchema} declares (drift-guarded by datasource.test.ts). */ const DATASOURCE_KEYS = [ - 'name', 'label', 'driver', 'config', 'pool', 'readReplicas', 'capabilities', + 'name', 'label', 'driver', 'config', 'pool', 'capabilities', 'healthCheck', 'ssl', 'retryPolicy', 'description', 'active', 'autoConnect', 'schemaMode', 'external', 'origin', ] as const; @@ -67,28 +74,31 @@ const DATASOURCE_RETRY_POLICY_KEYS = ['maxRetries', 'baseDelayMs', 'maxDelayMs', /** * A connection detail written one level too high — it belongs inside `config`. * - * This prescription stops at *where to put it* and deliberately does NOT promise - * that the move gets validated. It used to: the sentence read "the driver's own - * configSchema validates it there", and that was false twice over — - * {@link DriverDefinitionSchema}'s `configSchema` is a `z.record` that both - * bundled driver specs set to `{}`, and nothing in this repo reads it (#4410). - * - * Which made this the worst line in the module: it took an author who had made a - * recoverable mistake at a place that now catches it, and pointed them — with the - * platform's authority — at a slot where the same mistake is silent again. - * `config: { hostname: … }` is stripped in silence and the datasource connects on - * localhost, which is #4001's original bug verbatim, one level down. A wrong - * instruction is worse than none, and worst of all for an AI author, whose only - * check on "did that work?" is whether the parse complained. + * This prescription makes a validation claim again, and #4410 is what made the + * claim true. Between #4001 and #4410 it did not: the sentence read "the + * driver's own configSchema validates it there", which was false twice over — + * {@link DriverDefinitionSchema}'s `configSchema` was a `z.record` both bundled + * driver specs set to `{}`, and nothing read it. That made this the worst line + * in the module: it took an author who had made a *recoverable* mistake at a + * place that catches it, and pointed them — with the platform's authority — at a + * slot where the same mistake was silent again. `config: { hostname: … }` was + * stripped in silence and the datasource connected on localhost, which is + * #4001's original bug verbatim, one level down. A wrong instruction is worse + * than none, and worst of all for an AI author, whose only check on "did that + * work?" is whether the parse complained. * - * Naming the per-driver schema is the honest form: it is the shape to write - * against, and a reader can check themselves against it even while nothing - * enforces it. Restore a validation claim here only when #4410 makes one true. + * Note the SECOND thing #4410 had to fix for this line to be safe: the target + * must be the key the driver contract actually declares. Prescribing + * `config: { user: … }` when the postgres contract spells it `username` would + * have swapped a one-step correction for a two-step one — reject at the top, + * reject again inside — so `canonical` names the landing key, not the one the + * author happened to type. */ -const belongsInConfig = (key: string) => +const belongsInConfig = (key: string, canonical: string = key) => `\`${key}\` is a driver connection detail — it belongs inside \`config\`, not at the top ` - + `level. Move it to \`config: { ${key}: … }\`, matching your driver's config shape ` - + `(\`PostgresConfigSchema\` / \`MongoConfigSchema\` / \`MemoryConfigSchema\` in \`data/driver/\`).`; + + `level. Move it to \`config: { ${canonical}: … }\`, which is parsed against your driver's ` + + `config contract (\`PostgresConfigSchema\` / \`MysqlConfigSchema\` / \`SqliteConfigSchema\` / ` + + `\`MongoConfigSchema\` / \`MemoryConfigSchema\`, exported from \`@objectstack/spec/data\`).`; const driverDefinitionUnknownKeyError = strictUnknownKeyError({ surface: 'this driver definition', @@ -122,9 +132,18 @@ const externalSettingsUnknownKeyError = strictUnknownKeyError({ password: '`password` must never be inlined. Put the secret in the secrets store and reference ' + 'it with `credentialsRef` (e.g. `credentialsRef: "secret:warehouse/password"`).', + // #4487 corrected the second half of this line. It used to offer + // `capabilities.readOnly` as the place to "describe the driver" — a key the + // liveness audit found has NO reader (liveness/datasource.json), so an + // author who took the advice believed they had marked a datasource + // non-writable and had not. Same defect as the pre-#4410 `belongsInConfig` + // line documented above, on a property whose whole point is safety: a + // prescription must land somewhere enforced, and `allowWrites` is the only + // write gate there is. readOnly: - '`readOnly` is not an external-settings key. Use `allowWrites: false` here for the ' - + 'datasource-wide gate, or `capabilities.readOnly` to describe the driver.', + '`readOnly` is not an external-settings key. Use `allowWrites: false` here — it is the ' + + 'enforced datasource-wide write gate (checked by the ObjectQL engine before any write ' + + 'to a federated datasource).', }, history: 'Until #4001 these were dropped silently — federation ran on the defaults instead.', }); @@ -145,6 +164,34 @@ const externalValidationUnknownKeyError = strictUnknownKeyError({ + '(fail on mismatch, check at boot) regardless of what was written.', }); +/** + * `datasource.readReplicas` — retired (#4468, ADR-0049 enforce-or-remove). + * + * The full lifecycle of a declared-only key, in one slot. `readReplicas` was + * declared, `.strict()`-guarded, and #4410 even taught it to validate each + * entry against the driver's config contract — so a typo'd replica host was + * rejected with a precise fix-it error. What none of that established is that + * anything ever *connects* to a replica: `ConnectableDatasource` and + * `DatasourceConnectionSpec` have no replicas field, the driver factory never + * reads the key, and no query path distinguishes a read from a write. The + * platform has no read/write splitting at all, so there was no seam for this to + * plug into. #4410 made the validation better without making the feature exist, + * which is the trap ADR-0049 names: precision applied to an inert slot reads as + * evidence the slot is live. + * + * `replicas` shares this prescription rather than aliasing to the removed key — + * an author who spelled it the other way deserves the same explanation, not a + * rename onto a key that is also gone. + */ +const RETIRED_READ_REPLICAS = + '`datasource.readReplicas` was removed in @objectstack/spec 17.0.0 (#4468, ADR-0049) — ' + + 'it described replica connections nothing ever opened: no driver reads the key, and no ' + + 'query path separates reads from writes, so every statement always went to the primary. ' + + 'Delete the key. There is no read-replica routing to migrate to — if your database fronts ' + + 'its replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint), point ' + + '`config` at that endpoint, which is the only read-scaling path that works today. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.'; + const datasourceUnknownKeyError = strictUnknownKeyError({ surface: 'this datasource', knownKeys: DATASOURCE_KEYS, @@ -155,7 +202,6 @@ const datasourceUnknownKeyError = strictUnknownKeyError({ options: 'config', enabled: 'active', pooling: 'pool', - replicas: 'readReplicas', mode: 'schemaMode', schema_mode: 'schemaMode', federation: 'external', @@ -166,15 +212,17 @@ const datasourceUnknownKeyError = strictUnknownKeyError({ host: belongsInConfig('host'), port: belongsInConfig('port'), database: belongsInConfig('database'), - user: belongsInConfig('user'), + user: belongsInConfig('user', 'username'), username: belongsInConfig('username'), filename: belongsInConfig('filename'), url: belongsInConfig('url'), - connectionString: belongsInConfig('connectionString'), + connectionString: belongsInConfig('connectionString', 'url'), password: '`password` must never be inlined on a datasource. Interpolate it from the environment ' + 'inside `config`, or for an external datasource reference the secrets store via ' + '`external.credentialsRef`.', + readReplicas: RETIRED_READ_REPLICAS, + replicas: RETIRED_READ_REPLICAS, }, history: 'Until #4001 these were dropped silently — a connection key written one level too high ' @@ -300,11 +348,21 @@ export const DriverDefinitionSchema = lazySchema(() => z.object({ /** * Configuration Schema (JSON Schema) - * Describes the structure of the `config` object needed for this driver. - * Used by the UI to generate the connection form. + * + * The structure of the `config` object this driver needs — rendered by the + * Studio connection form (`GET /api/v1/datasources/drivers`) and, for the + * built-in drivers, the JSON-Schema projection of the very zod schema + * `DatasourceSchema` parses `config` against. Form and gate therefore describe + * one shape by construction. + * + * Both bundled driver specs used to set this to `{}`, one of them with a + * comment promising it would be "populated at runtime" by code that did not + * exist; nothing read the field either (#4410). Fill it from a real schema — + * an empty object here means the connection form has nothing to render and + * says so, which is the loud version of the same absence. */ configSchema: z.record(z.string(), z.unknown()).describe('JSON Schema for connection configuration'), - + /** * Default Capabilities * What this driver supports out-of-the-box. @@ -312,6 +370,9 @@ export const DriverDefinitionSchema = lazySchema(() => z.object({ capabilities: z.lazy(() => DatasourceCapabilities).optional(), }, { error: driverDefinitionUnknownKeyError }).strict()); +/** A driver definition — {@link DriverDefinitionSchema}'s parsed shape. */ +export type DriverDefinition = z.infer; + /** * Datasource Capabilities Schema * Declares what this datasource naturally supports. @@ -416,6 +477,30 @@ export const ExternalDatasourceSettingsSchema = z.object({ export type ExternalDatasourceSettings = z.infer; +/** + * Replay a driver-config parse onto the datasource's own issue list (#4410). + * + * A no-op for a driver the platform ships no contract for — `known: false` is + * the registry saying "nothing to check against", which is deliberately NOT the + * same answer as "checked and clean". + */ +function reportDriverConfigIssues( + ctx: z.RefinementCtx, + driver: unknown, + config: unknown, + basePath: (string | number)[], +): void { + const result = validateDriverConfig(driver, config); + if (!result.known) return; + for (const issue of result.issues) { + ctx.addIssue({ + code: 'custom', + path: [...basePath, ...issue.path], + message: issue.message, + }); + } +} + /** * Datasource Schema * Represents a connection to an external data store. @@ -448,12 +533,9 @@ export const DatasourceSchema = lazySchema(() => z.object({ connectionTimeoutMillis: z.number().default(3000).describe('Connection establishment timeout'), }, { error: poolUnknownKeyError }).strict().optional().describe('Connection pool settings'), - /** - * Read Replicas - * Optional list of duplicate configurations for read-only operations. - * Useful for scaling read throughput. - */ - readReplicas: z.array(z.record(z.string(), z.unknown())).optional().describe('Read-only replica configurations'), + // `readReplicas` was removed here (#4468) — see RETIRED_READ_REPLICAS. It + // declared replica connections nothing opened; read/write splitting does not + // exist in the platform, so there was no consumer for it to reach. /** * Capability Overrides @@ -530,7 +612,25 @@ export const DatasourceSchema = lazySchema(() => z.object({ */ origin: z.enum(['code', 'runtime']).default('code') .describe('Datasource provenance (server-managed, read-only)'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // MISSING until the registered-type invariant test was written: `datasource` + // closed strict in the #4001 data step without declaring it, so the + // `_packageId` / `_provenance` that `MetadataPlugin` stamps on every + // registered type were REJECTED here. Same live defect as `hook`, and the + // same one `permission` hit as a 422 on the ADR-0094 overlay path before + // Tier-A declared them (#4001 findings log, entries 2/8). + ...MetadataProtectionFields, }, { error: datasourceUnknownKeyError }).strict().superRefine((ds, ctx) => { + // The `config` gate (#4410). `config` is parsed against the contract for the + // declared driver and every issue is re-pathed under the slot it came from — + // the author sees `config.hostname`, not a detached message. + // + // #4410 ran this over each `readReplicas` entry too. #4468 removed that along + // with the key: validating entries for connections nothing opens spends the + // author's trust on a slot that cannot pay it back. + reportDriverConfigIssues(ctx, ds.driver, ds.config, ['config']); + if (ds.schemaMode !== 'managed' && !ds.external) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts new file mode 100644 index 0000000000..d026221e45 --- /dev/null +++ b/packages/spec/src/data/driver/common.zod.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Shared building blocks for the per-driver `datasource.config` shapes (#4410). + * + * Every schema under `data/driver/` describes ONE driver's `config` slot — the + * keys an author may write and the platform actually reads. They are the + * enforcement half of the `config` escape hatch `datasource.zod.ts` opens: the + * slot stays `z.record` at the top of `DatasourceSchema` because a sqlite + * `filename` and a postgres `host` share no shape, and `DatasourceSchema`'s + * refinement then parses it against the schema for the declared driver. + * + * The rule these files are written to: **a key is declared here only if some + * code path reads it.** A config key that no driver and no factory consumes is + * the same silent-strip defect one level down (#4001, ADR-0078), so an unread + * key is either wired or rejected with a prescription — never left in the + * contract to look supported. + */ + +/** + * Dev-only, loosen-only schema self-heal (#2186), honoured by the SQL drivers. + * + * Read by `createDefaultDatasourceDriverFactory` and passed to + * `SqlDriver.autoMigrate`; force-disabled under `NODE_ENV=production`. `'safe'` + * applies only non-destructive alters (relax NOT NULL, widen varchar). + */ +export const SqlAutoMigrateSchema = z.enum(['off', 'safe']) + .describe('Dev-only non-destructive schema self-heal (#2186)'); + +export type SqlAutoMigrate = z.infer; + +/** + * `schemaMode` written inside `config`. Shared by every SQL driver: the factory + * used to look for it there because the datasource-level key was dropped + * between the record and the connection spec, so the nested copy was the only + * spelling that reached a driver. #4410 carries the declared key down instead. + */ +export const SCHEMA_MODE_BELONGS_ON_DATASOURCE = + '`schemaMode` is a datasource-level key, not driver config. Write it next to `driver` ' + + "(`schemaMode: 'external'`) — the connection service now carries it down to the driver, so " + + 'the copy inside `config` is gone rather than duplicated.'; + +/** `readOnly` written inside `config`. Shared by every driver. */ +export const READ_ONLY_BELONGS_ON_DATASOURCE = + '`readOnly` is not driver config. Use `capabilities: { readOnly: true }` on the datasource to ' + + 'declare the connection read-only, or `external.allowWrites: false` for a federated database.'; + +/** + * TLS on/off for a SQL driver — the shorthand, and deliberately ONLY the + * shorthand. + * + * Certificates live in the datasource's own `ssl` block (`enabled`, + * `rejectUnauthorized`, `ca`, `cert`, `key`), which #4410 wired through to the + * client; before that it was declared, strict, documented and read by nobody, + * so the only TLS setting that did anything was this per-driver one. Two slots + * for the same setting is one too many, and this is the one that has to stay + * narrow: it is what the Studio connection form renders from, and the form + * turns anything that is not a boolean / enum / number into a TEXT INPUT. A + * `boolean | object` union here would hand the wizard a text box whose every + * value the gate then rejects — a form that cannot produce a saveable record. + */ +export const DriverSslToggleSchema = z.boolean() + .describe('Enable TLS. Certificates go in the datasource-level `ssl` block.'); + +/** Where the certificate-bearing form of TLS lives. */ +export const SSL_DETAIL_BELONGS_ON_DATASOURCE = + 'Certificates and verification live in the datasource-level `ssl` block, not in driver config: ' + + '`ssl: { enabled: true, rejectUnauthorized: false, ca: … }` next to `driver`. Inside `config`, ' + + '`ssl` is the on/off shorthand only.'; + +/** Options every driver-config JSON-Schema projection is built with. */ +const TO_JSON_SCHEMA = { + target: 'draft-2020-12', + // The AUTHOR-facing shape: a key with a `.default()` is optional to write. + io: 'input', + // The memory driver's `persistence` accepts a custom adapter — an object of + // functions, which has no JSON-Schema form. Emitting `{}` for it keeps the + // connection form renderable instead of throwing at boot (#3746 hazard). + unrepresentable: 'any', +} as const; + +/** + * Memoized JSON-Schema projection of a driver-config schema. + * + * One projection per schema, computed on first use and cached: this is what + * `DriverDefinitionSchema.configSchema` publishes and what the Studio + * connection form renders, so the form and the parse gate cannot describe + * different shapes — they are the same zod object seen twice. + */ +export function driverConfigJsonSchema(schema: z.ZodType): () => Record { + let cached: Record | undefined; + return () => { + if (cached === undefined) { + cached = z.toJSONSchema(schema, TO_JSON_SCHEMA) as Record; + } + return cached; + }; +} diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts new file mode 100644 index 0000000000..25698350b3 --- /dev/null +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { + BUILTIN_DRIVER_IDS, + DRIVER_CONFIG_SCHEMAS, + DRIVER_ID_ALIASES, + getDriverConfigJsonSchemaById, + getDriverConfigSchema, + resolveDriverId, + validateDriverConfig, +} from './config-registry.zod'; + +describe('driver config registry', () => { + it('ships a schema and a JSON-Schema projection for every canonical id', () => { + for (const id of BUILTIN_DRIVER_IDS) { + expect(DRIVER_CONFIG_SCHEMAS[id], id).toBeTruthy(); + const json = getDriverConfigJsonSchemaById(id) as { type?: string; properties?: object }; + expect(json.type, id).toBe('object'); + expect(json.properties, id).toBeTruthy(); + } + }); + + it('memoizes each projection so the form and the gate share one object', () => { + expect(getDriverConfigJsonSchemaById('postgres')).toBe(getDriverConfigJsonSchemaById('postgres')); + }); + + it('resolves every alias onto a canonical id that has a contract', () => { + for (const [alias, canonical] of Object.entries(DRIVER_ID_ALIASES)) { + expect(resolveDriverId(alias), alias).toBe(canonical); + expect(BUILTIN_DRIVER_IDS).toContain(canonical); + } + }); + + it('resolves case- and whitespace-insensitively', () => { + expect(resolveDriverId(' PostgreSQL ')).toBe('postgres'); + expect(resolveDriverId('MongoDB')).toBe('mongo'); + }); + + /** + * The distinction the whole gate rests on: "nothing to check against" is not + * the same answer as "checked and clean", and a caller that conflates them + * reintroduces the silence #4410 removed. + */ + it('reports an unknown driver as unknown rather than as valid', () => { + expect(resolveDriverId('com.vendor.snowflake')).toBeUndefined(); + expect(getDriverConfigSchema('com.vendor.snowflake')).toBeUndefined(); + expect(validateDriverConfig('com.vendor.snowflake', { whatever: 1 })).toEqual({ known: false }); + }); + + it('validates a known driver and returns path-relative issues', () => { + const result = validateDriverConfig('pg', { database: 'app', hostname: 'db.internal' }); + + expect(result.known).toBe(true); + expect(result).toHaveProperty('issues'); + const issues = (result as { issues: Array<{ path: unknown[]; message: string }> }).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain('`hostname` → `host`'); + }); +}); + +describe('DatasourceSchema × driver config (#4410)', () => { + const base = { name: 'warehouse', driver: 'postgres' }; + + /** + * The reported bug, verbatim: the correct key is `host`, `hostname` was + * accepted in silence, and the datasource then connected to localhost while + * reporting success — which for an AI author is indistinguishable from having + * configured it. + */ + it('rejects a misspelled connection key inside config, pathed at the key', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { hostname: 'db.internal', database: 'analytics' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['config']); + expect(result.error!.issues[0]!.message).toContain('`hostname` → `host`'); + }); + + it('accepts the corrected config', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { host: 'db.internal', database: 'analytics' }, + }); + + expect(result.success).toBe(true); + }); + + it('leaves a plugin-contributed driver`s config alone', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'com.vendor.snowflake', + config: { account: 'xy12345', warehouse: 'COMPUTE_WH' }, + }); + + expect(result.success).toBe(true); + }); + + it('validates every driver id alias the same way', () => { + for (const alias of ['pg', 'postgresql', 'POSTGRES']) { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: alias, + config: { hostname: 'db.internal', database: 'analytics' }, + }); + expect(result.success, alias).toBe(false); + } + }); + + /** + * #4410 extended this gate over `readReplicas` too. #4468 retired the key — + * nothing ever opened a replica connection — so the parse must now REJECT the + * slot rather than check what goes in it. Pinned here, next to the config + * cases, because the two are easy to re-conflate: both are per-driver record + * shapes, and only one of them has a consumer. + */ + it('rejects readReplicas outright, with the retirement prescription', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { host: 'db.internal', database: 'analytics' }, + readReplicas: [{ host: 'replica-1.internal', database: 'analytics' }], + }); + + expect(result.success).toBe(false); + // A well-formed replica block: the rejection is about the key existing at + // all, not about anything being wrong inside it. + expect(result.error!.issues[0]!.message).toMatch( + /`datasource\.readReplicas` was removed.*no query path separates reads from writes.*Delete the key/s, + ); + }); + + it('rejects a sqlite datasource whose filename is misspelled', () => { + const result = DatasourceSchema.safeParse({ + name: 'local', + driver: 'sqlite', + config: { file: './data.db' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`file` → `filename`'); + }); + + it('still reports the schemaMode/external coherence rule alongside a config problem', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'analytics' }, + schemaMode: 'external', + }); + + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('config'); + expect(paths).toContain('external'); + }); +}); diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts new file mode 100644 index 0000000000..86f9ee3fbf --- /dev/null +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { z } from 'zod'; + +import { getMemoryConfigJsonSchema, MemoryConfigSchema } from './memory.zod'; +import { getMongoConfigJsonSchema, MongoConfigSchema } from './mongo.zod'; +import { getMysqlConfigJsonSchema, MysqlConfigSchema } from './mysql.zod'; +import { getPostgresConfigJsonSchema, PostgresConfigSchema } from './postgres.zod'; +import { + getSqliteConfigJsonSchema, + getSqliteWasmConfigJsonSchema, + SqliteConfigSchema, + SqliteWasmConfigSchema, +} from './sqlite.zod'; + +/** + * The driver-id → `datasource.config` shape registry (#4410). + * + * ## Why this exists + * + * `DatasourceSchema` went `.strict()` in #4207 with `config` deliberately left + * open — per-driver by construction, a sqlite `filename` and a postgres `host` + * share no shape. The module comment justified the hole by saying "the driver's + * own `configSchema` is what validates it". Nothing did: the two bundled driver + * specs set `configSchema: {}`, no code read the field, and the three per-driver + * zod schemas were not even exported from the package. So the one slot an author + * writes by hand was the one slot with no gate, and `config: { hostname: … }` + * connected to localhost while reporting success. + * + * This registry closes that: it maps every driver id the platform can actually + * BUILD onto the schema for that driver's config, and `DatasourceSchema` parses + * `config` against it. + * + * #4410 ran the same parse over each `readReplicas` entry. #4468 retired that + * key: no driver ever opened a replica connection, so the entries were being + * checked against a contract nothing would ever apply them to. Worth keeping in + * view here — "we validate what we can construct" is a claim about drivers we + * build, and a slot the platform never connects is outside it in the other + * direction. + * + * ## Where the boundary is + * + * An id this registry does not know stays unvalidated, and that is deliberate + * rather than a remaining hole: `driver` is an open namespace — a plugin ships + * `com.vendor.snowflake` with its own config shape, and rejecting keys against a + * shape we do not have would be worse than the silence it replaces. The honest + * line is "we validate what we can construct", and + * {@link BUILTIN_DRIVER_IDS} is exactly the set the shared + * `createDefaultDatasourceDriverFactory` builds. + * + * ## Why the alias table lives HERE + * + * The factory had its own copy. Two tables meant the id that selects a driver + * and the id that selects its config schema could disagree — validating a `pg` + * datasource against nothing while building it as postgres — so the factory now + * imports {@link resolveDriverId} instead of keeping a second list. + */ + +/** Canonical driver ids the platform ships a config contract for. */ +export const BUILTIN_DRIVER_IDS = [ + 'memory', + 'sqlite', + 'sqlite-wasm', + 'postgres', + 'mysql', + 'mongo', +] as const; + +export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; + +/** + * Accepted spellings of each canonical driver id, matched case-insensitively. + * + * These are DRIVER SELECTORS, not config keys: `driver: 'pg'` and + * `driver: 'postgres'` build the same driver, so they must resolve to the same + * config contract. (Unknown-key tolerance inside `config` is a different + * question, and the answer there is a rejection with a rename hint.) + */ +export const DRIVER_ID_ALIASES: Readonly> = { + memory: 'memory', + inmemory: 'memory', + 'in-memory': 'memory', + mingo: 'memory', + sqlite: 'sqlite', + sqlite3: 'sqlite', + 'better-sqlite3': 'sqlite', + 'sqlite-wasm': 'sqlite-wasm', + 'wasm-sqlite': 'sqlite-wasm', + postgres: 'postgres', + postgresql: 'postgres', + pg: 'postgres', + mysql: 'mysql', + mysql2: 'mysql', + mariadb: 'mysql', + mongo: 'mongo', + mongodb: 'mongo', +}; + +/** + * Resolve an authored `datasource.driver` onto its canonical id, or `undefined` + * when the platform ships no contract for it (a plugin-contributed driver). + */ +export function resolveDriverId(driver: unknown): BuiltinDriverId | undefined { + if (typeof driver !== 'string') return undefined; + return DRIVER_ID_ALIASES[driver.trim().toLowerCase()]; +} + +/** Canonical driver id → the schema its `datasource.config` must satisfy. */ +export const DRIVER_CONFIG_SCHEMAS: Readonly> = { + memory: MemoryConfigSchema, + sqlite: SqliteConfigSchema, + 'sqlite-wasm': SqliteWasmConfigSchema, + postgres: PostgresConfigSchema, + mysql: MysqlConfigSchema, + mongo: MongoConfigSchema, +}; + +/** + * The config schema for an authored `driver` value, following aliases. + * `undefined` means "no contract shipped" — the caller must leave the config + * alone rather than invent a verdict for it. + */ +export function getDriverConfigSchema(driver: unknown): z.ZodType | undefined { + const id = resolveDriverId(driver); + return id ? DRIVER_CONFIG_SCHEMAS[id] : undefined; +} + +/** Canonical driver id → the memoized JSON-Schema projection of its config shape. */ +const DRIVER_CONFIG_JSON_SCHEMAS: Readonly Record>> = { + memory: getMemoryConfigJsonSchema, + sqlite: getSqliteConfigJsonSchema, + 'sqlite-wasm': getSqliteWasmConfigJsonSchema, + postgres: getPostgresConfigJsonSchema, + mysql: getMysqlConfigJsonSchema, + mongo: getMongoConfigJsonSchema, +}; + +/** + * JSON-Schema projection of a built-in driver's config contract — what + * `DriverDefinitionSchema.configSchema` publishes and what the Studio + * connection form renders. + * + * Takes a CANONICAL id (not an alias) so a caller enumerating drivers cannot + * quietly get `undefined` for a spelling it thought was covered; use + * {@link resolveDriverId} first when the id came from authored metadata. + */ +export function getDriverConfigJsonSchemaById(id: BuiltinDriverId): Record { + return DRIVER_CONFIG_JSON_SCHEMAS[id](); +} + +/** One problem found in a `datasource.config`, path-relative to the config object. */ +export interface DriverConfigIssue { + /** Property path inside `config` (empty for a whole-object problem). */ + path: (string | number)[]; + message: string; +} + +/** + * Validate a `datasource.config` against its driver's contract. + * + * Returns `{ known: false }` for a driver the platform ships no contract for, + * so callers can distinguish "checked and clean" from "nothing to check + * against" — a distinction the silent-strip failure mode depends on nobody + * making. Never throws. + */ +export function validateDriverConfig( + driver: unknown, + config: unknown, +): { known: false } | { known: true; issues: DriverConfigIssue[] } { + const schema = getDriverConfigSchema(driver); + if (!schema) return { known: false }; + const result = schema.safeParse(config ?? {}); + if (result.success) return { known: true, issues: [] }; + return { + known: true, + issues: result.error.issues.map((issue) => ({ + path: [...issue.path] as (string | number)[], + message: issue.message, + })), + }; +} diff --git a/packages/spec/src/data/driver/index.ts b/packages/spec/src/data/driver/index.ts new file mode 100644 index 0000000000..16a8b904d0 --- /dev/null +++ b/packages/spec/src/data/driver/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Per-driver `datasource.config` contracts (#4410). + * + * These shapes existed since the protocol's early days and were reachable from + * nothing — no barrel exported `data/driver/`, so the schemas `datasource.zod.ts` + * told authors to write against could not even be imported. They are exported + * here because they are now load-bearing: `DatasourceSchema` parses `config` + * against them, `DriverDefinitionSchema.configSchema` publishes their JSON-Schema + * projection, and the Studio connection form renders from that same projection. + */ + +export * from './common.zod'; +export * from './config-registry.zod'; +export * from './memory.zod'; +export * from './mongo.zod'; +export * from './mysql.zod'; +export * from './postgres.zod'; +export * from './sqlite.zod'; diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index b5098ea798..5a2c8a351c 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -25,8 +25,6 @@ describe('MemoryConfigSchema', () => { expect(config.strictMode).toBe(false); expect(config.initialData).toBeUndefined(); expect(config.persistence).toBe(false); - expect(config.indexes).toBeUndefined(); - expect(config.maxRecordsPerObject).toBeUndefined(); }); it('still accepts "auto" as an explicit opt-in to the previous behaviour', () => { @@ -179,25 +177,26 @@ describe('MemoryConfigSchema', () => { expect(typeof p.adapter.flush).toBe('function'); }); - it('should accept config with indexes', () => { - const config = MemoryConfigSchema.parse({ - indexes: { - users: ['email', 'role'], - posts: ['author_id', 'status'], - }, + // `indexes` and `maxRecordsPerObject` were declared here and read by nobody: + // `InMemoryDriverConfig` has no field for either, the driver keeps no indexes + // (every read is a linear Mingo scan) and evicts nothing. These tests used to + // assert they were "accepted" — which was true, and meant nothing. #4410 gave + // `config` a gate, so a key inside it now claims to be honoured; both were + // removed rather than blessed, and the rejection carries the reason. + it('rejects `indexes`, which the memory driver never kept', () => { + const result = MemoryConfigSchema.safeParse({ + indexes: { users: ['email', 'role'] }, }); - expect(config.indexes).toBeDefined(); - expect(config.indexes!.users).toEqual(['email', 'role']); - expect(config.indexes!.posts).toEqual(['author_id', 'status']); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('the memory driver keeps no indexes'); }); - it('should accept config with maxRecordsPerObject', () => { - const config = MemoryConfigSchema.parse({ - maxRecordsPerObject: 10000, - }); + it('rejects `maxRecordsPerObject`, which the memory driver never enforced', () => { + const result = MemoryConfigSchema.safeParse({ maxRecordsPerObject: 10000 }); - expect(config.maxRecordsPerObject).toBe(10000); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('the memory driver evicts nothing'); }); it('should accept full config with all options', () => { @@ -211,18 +210,12 @@ describe('MemoryConfigSchema', () => { path: '/var/data/memory.json', autoSaveInterval: 3000, }, - indexes: { - users: ['email'], - }, - maxRecordsPerObject: 50000, }); expect(config.strictMode).toBe(true); expect(config.initialData!.users).toHaveLength(1); const p = config.persistence as { type: 'file'; path?: string }; expect(p.path).toBe('/var/data/memory.json'); - expect(config.indexes!.users).toEqual(['email']); - expect(config.maxRecordsPerObject).toBe(50000); }); it('should reject file persistence with invalid autoSaveInterval', () => { diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 9a732840f9..321fe233f5 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -1,11 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { DriverDefinitionSchema } from '../datasource.zod'; + +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import type { DriverDefinition } from '../datasource.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, +} from './common.zod'; /** * Memory Driver Configuration Schema - * + * * Defines the configuration options for the in-memory driver. * Reference: objectql/packages/drivers/memory (Mingo-powered production-ready driver) * @@ -158,6 +165,50 @@ export const MemoryPersistenceConfigSchema = lazySchema(() => z.union([ // 2. Connection Configuration // ========================================================================== +const MEMORY_CONFIG_KEYS = ['initialData', 'strictMode', 'persistence'] as const; + +/** + * Two keys were declared here and read by nobody: `indexes` and + * `maxRecordsPerObject`. `InMemoryDriverConfig` (`driver-memory`) has no field + * for either — the driver indexes nothing (its reads are a linear Mingo scan) + * and evicts nothing (there is no LRU) — so an author who bounded a store or + * asked for an index got a clean parse and no behaviour. #4410's enforce step + * is what surfaced them: giving `config` a gate means every key inside it now + * claims to be honoured, so a key that is not gets removed rather than blessed. + * Both are rejected with the prescription below (ADR-0049 enforce-or-remove). + */ +const memoryConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this memory datasource's config", + knownKeys: MEMORY_CONFIG_KEYS, + aliases: { + data: 'initialData', + seed: 'initialData', + seeddata: 'initialData', + strict: 'strictMode', + persist: 'persistence', + persistent: 'persistence', + }, + guidance: { + indexes: + '`indexes` was declared but never read: the memory driver keeps no indexes — every read is ' + + 'a linear Mingo scan — so it changed nothing. Drop it, or move the datasource to a ' + + 'driver that indexes (`sqlite` / `postgres`), where object-level `indexes` apply.', + maxRecordsPerObject: + '`maxRecordsPerObject` was declared but never read: the memory driver evicts nothing, so a ' + + 'bound here was never enforced and the store grew unbounded regardless. Drop it and bound ' + + 'the data you load, or use a driver with real storage limits.', + filename: + '`filename` is a sqlite key. For a memory datasource that survives restarts set ' + + "`persistence: 'file'` (the file is scoped per datasource); for a real file-backed SQL " + + "database set `driver: 'sqlite'`.", + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised key was accepted ' + + 'in silence and the store came up on the driver defaults instead.', +}); + export const MemoryConfigSchema = lazySchema(() => z.object({ /** * Initial data to pre-populate the in-memory store. @@ -238,45 +289,41 @@ export const MemoryConfigSchema = lazySchema(() => z.object({ * so two pools that DO opt in still need it to avoid aliasing one file. */ persistence: MemoryPersistenceConfigSchema.or(z.literal(false)).default(false).describe('Persistence configuration (opt-in; defaults to pure in-memory)'), +}, { error: memoryConfigUnknownKeyError }).strict() + .describe('Memory Driver Connection Configuration')); - /** - * Fields to index for faster lookups. - * Maps object names to arrays of field names to index. - * - * @example - * { - * users: ['email', 'role'], - * posts: ['author_id', 'status'] - * } - */ - indexes: z.record( - z.string(), - z.array(z.string()) - ).optional().describe('Index configuration per object'), - - /** - * Maximum number of records per object type. - * When exceeded, oldest records may be evicted (LRU). - * Useful for caching or bounded memory usage. - */ - maxRecordsPerObject: z.number().min(1).optional().describe('Max records per object (memory bound)'), - -}).describe('Memory Driver Connection Configuration')); +/** + * JSON-Schema projection of {@link MemoryConfigSchema}, memoized — what + * {@link MemoryDriverSpec} publishes as its `configSchema`. + * + * The custom-adapter branch of `persistence` is an object of functions, which + * has no JSON-Schema form; the shared projection emits `{}` for it rather than + * throwing, so the connection form stays renderable. + */ +export const getMemoryConfigJsonSchema = driverConfigJsonSchema(MemoryConfigSchema); // ========================================================================== // 3. Driver Definition (Metadata) // ========================================================================== /** - * The static definition of the Memory driver's capabilities and default metadata. - * Implements the `DriverDefinitionSchema` contract. + * The static definition of the Memory driver's capabilities and default + * metadata, satisfying the `DriverDefinitionSchema` contract (proved by + * `memory.test.ts`, which parses this constant). + * + * `configSchema` was `{}` here — a declared slot that nothing filled and + * nothing read (#4410). It now projects {@link MemoryConfigSchema}, lazily, so + * the shape the connection form renders and the shape `DatasourceSchema` + * enforces are the same object seen twice. */ -export const MemoryDriverSpec = DriverDefinitionSchema.parse({ +export const MemoryDriverSpec = { id: 'memory', label: 'In-Memory', description: 'High-performance in-memory driver powered by Mingo (MongoDB-compatible query engine). Supports filtering, aggregation pipelines, sorting, projection.', icon: 'memory', - configSchema: {}, + get configSchema() { + return getMemoryConfigJsonSchema(); + }, capabilities: { transactions: true, // Query @@ -290,10 +337,12 @@ export const MemoryDriverSpec = DriverDefinitionSchema.parse({ querySubqueries: false, // No full-text search (linear scan) fullTextSearch: false, + // Not read-only + readOnly: false, // Dynamic schema (no DDL needed) dynamicSchema: true, }, -}); +} satisfies DriverDefinition; // ========================================================================== // 4. Derived Types diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index c41c6a00a6..a41922dbb2 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -1,78 +1,163 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { DriverDefinitionSchema } from '../datasource.zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import type { DriverDefinition } from '../datasource.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, +} from './common.zod'; /** * MongoDB Standard Driver Protocol * * Describes the MongoDB connection settings and capabilities. * - * CONTRACT ONLY — nothing parses `datasource.config` against this. This block - * used to claim it was "used by the Platform to validate `datasource.config` - * when `driver: 'mongo'`", which was never true: the config slot is a `z.record` - * and this schema has no consumer (#4410). It is the shape to author against, - * not a gate that runs. Say "validates" here again only once #4410 lands. + * ENFORCED as of #4410. This block used to claim it was "used by the Platform + * to validate `datasource.config` when `driver: 'mongo'`", which was false: the + * config slot was a bare `z.record` and this schema had no consumer at all — + * not even an export, since `data/driver/` was reachable only from its own + * tests. It is now what `DatasourceSchema` parses `config` against for a mongo + * datasource, and the same schema is projected onto + * {@link MongoDriverSpec}.configSchema for the connection form. */ // ========================================================================== // 1. Connection Configuration // ========================================================================== -import { lazySchema } from '../../shared/lazy-schema'; +const MONGO_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'authSource', 'options', +] as const; + +const mongoConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this mongo datasource's config", + knownKeys: MONGO_CONFIG_KEYS, + aliases: { + uri: 'url', + connectionstring: 'url', + dsn: 'url', + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + authdb: 'authSource', + authdatabase: 'authSource', + replicaset: 'options', + }, + guidance: { + pool: + '`pool` is not driver config — connection pooling is configured once for every driver in ' + + "the datasource's own `pool` block, which the factory maps onto the Mongo client's " + + '`minPoolSize`/`maxPoolSize`. Move it next to `driver`.', + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ssl: + '`ssl` is not a top-level mongo key. TLS is a connection-string concern here: put it in ' + + '`url` (`?tls=true`) or in the `options` passthrough the Mongo client reads.', + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected to mongodb://localhost:27017 ' + + 'rather than failing.', +}); + export const MongoConfigSchema = lazySchema(() => z.object({ /** - * Connection URI (Standard Connection String) - * If provided, host/port/username/password fields may be ignored or merged depending on driver logic. - * Format: mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] + * Connection URI (standard connection string). When present it supersedes + * `host`/`port`/`database`/`username`/`authSource` — those are only used to + * COMPOSE a URI when none is given. + * Format: `mongodb://[username:password@]host1[:port1][,…][/[db][?options]]` */ - url: z.string().describe('Connection URI').optional(), + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URI' }), /** - * Database Name (Required) - * The logical database to store collections. + * Database name — the logical database holding the collections. + * Required unless `url` carries it. */ - database: z.string().min(1).describe('Database Name'), + database: z.string().min(1).optional().describe('Database name').meta({ title: 'Database' }), + + /** Hostname. Used only when `url` is absent. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), - /** Hostname (Optional if url is provided) */ - host: z.string().default('127.0.0.1').describe('Host address').optional(), + /** Port. Used only when `url` is absent. */ + port: z.number().int().default(27017).describe('Port number').meta({ title: 'Port' }), - /** Port (Optional, default 27017) */ - port: z.number().int().default(27017).describe('Port number').optional(), + /** Authentication user. Used only when `url` is absent. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), - /** Username for authentication */ - username: z.string().describe('Authentication Username').optional(), + /** + * Authentication password. Prefer `external.credentialsRef` — a datasource + * secret always wins over this value. + */ + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), - /** Password for authentication */ - password: z.string().describe('Authentication Password').optional(), - - /** Authentication Database (Defaults to admin or database name) */ - authSource: z.string().describe('Authentication Database').optional(), + /** Authentication database, when it differs from `database`. */ + authSource: z.string().optional().describe('Authentication database') + .meta({ title: 'Auth source' }), /** - * Connection Options - * Passthrough options to the underlying MongoDB driver (e.g. valid certs, timeouts) + * Passthrough options handed to the MongoDB client verbatim + * (`replicaSet`, `tls`, timeouts, …). */ - options: z.record(z.string(), z.unknown()).describe('Extra driver options (ssl, poolSize, etc)').optional(), -}).describe('MongoDB Connection Configuration')); + options: z.record(z.string(), z.unknown()).optional() + .describe('Extra MongoClient options (replicaSet, tls, timeouts, …)'), +}, { error: mongoConfigUnknownKeyError }).strict() + .describe('MongoDB Connection Configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A mongo datasource needs a connection target: set `database` (with `host`/`port`) or ' + + 'a full `url`. Neither was given, so the connection would fall back to ' + + 'mongodb://localhost:27017 with no database selected.', + }); + } + })); + +/** + * JSON-Schema projection of {@link MongoConfigSchema}, memoized — what + * {@link MongoDriverSpec} publishes as its `configSchema`. + */ +export const getMongoConfigJsonSchema = driverConfigJsonSchema(MongoConfigSchema); // ========================================================================== // 2. Driver Definition (Metadata) // ========================================================================== /** - * The static definition of the Mongo driver's capabilities and default metadata. - * This implements the `DriverDefinitionSchema` contract. + * The static definition of the Mongo driver's capabilities and default + * metadata, satisfying the `DriverDefinitionSchema` contract (proved by + * `mongo.test.ts`, which parses this constant). + * + * `configSchema` is a getter so the JSON-Schema projection is computed on first + * read rather than at module load — the same deferral `lazySchema` exists for, + * and what lets this constant drop its runtime import of `DatasourceSchema`'s + * module (a `.parse()` at module scope would have made the config registry and + * this file a cycle). It used to be `{}` with a comment promising it would be + * "populated with a JSON Schema version of MongoConfigSchema at runtime"; no + * such code ever existed (#4410), so the promise is discharged here rather than + * described. */ -export const MongoDriverSpec = DriverDefinitionSchema.parse({ +export const MongoDriverSpec = { id: 'mongo', label: 'MongoDB', description: 'Official MongoDB Driver for ObjectStack. Supports rich queries, aggregation, and atomic updates.', icon: 'database', - // Empty, and nothing fills it. This comment used to promise the field would be - // "populated with a JSON Schema version of MongoConfigSchema at runtime" — no - // such code exists here or in any consumer (#4410). - configSchema: {}, + get configSchema() { + return getMongoConfigJsonSchema(); + }, capabilities: { transactions: true, // Query @@ -80,11 +165,15 @@ export const MongoDriverSpec = DriverDefinitionSchema.parse({ queryAggregations: true, querySorting: true, queryPagination: true, + queryWindowFunctions: false, + querySubqueries: false, + joins: false, fullTextSearch: true, + readOnly: false, // Schema dynamicSchema: true, - } -}); + }, +} satisfies DriverDefinition; /** * Derived Types diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts new file mode 100644 index 0000000000..9bb6f6fcb3 --- /dev/null +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + DriverSslToggleSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, + SSL_DETAIL_BELONGS_ON_DATASOURCE, +} from './common.zod'; + +/** + * MySQL / MariaDB driver configuration — the `config` slot of a `datasource` + * whose `driver` resolves to `mysql` (`mysql2`). + * + * The driver id was offered by the connection form and buildable by the shared + * factory long before #4410, but had no config shape at all in `packages/spec` + * — postgres, mongo and memory each had one and mysql did not, so its `config` + * was the one slot with neither a gate nor a documented shape. + * + * Every key here is read by `createDefaultDatasourceDriverFactory` + * (→ `SqlDriver`, knex `mysql2`). Postgres-only knobs are deliberately absent: + * `mysql2` has no `application_name` and no `statement_timeout`, so declaring + * them would advertise settings the client drops. + */ +const MYSQL_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'ssl', 'autoMigrate', +] as const; + +const mysqlConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this mysql datasource's config", + knownKeys: MYSQL_CONFIG_KEYS, + aliases: { + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + schema: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + connectionstring: 'url', + dsn: 'url', + uri: 'url', + sslmode: 'ssl', + tls: 'ssl', + usessl: 'ssl', + }, + guidance: { + pool: + '`pool` is not driver config — connection pooling is configured once for every driver in ' + + "the datasource's own `pool` block. Move it next to `driver`.", + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ca: SSL_DETAIL_BELONGS_ON_DATASOURCE, + cert: SSL_DETAIL_BELONGS_ON_DATASOURCE, + key: SSL_DETAIL_BELONGS_ON_DATASOURCE, + rejectUnauthorized: SSL_DETAIL_BELONGS_ON_DATASOURCE, + charset: + '`charset` is not honoured: the factory builds the mysql2 connection from the keys listed ' + + 'here only. Put it in the `url` as a query parameter (`?charset=utf8mb4`) so the client ' + + 'actually receives it.', + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected on the client defaults ' + + '(localhost:3306) rather than failing.', +}); + +export const MysqlConfigSchema = lazySchema(() => z.object({ + /** + * Connection URI, passed to `mysql2` as-is when present. + * Format: `mysql://[user[:password]@][host][:port]/[dbname][?params]` + */ + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URL' }), + + /** Hostname or IP address. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), + + /** Port number. */ + port: z.number().int().default(3306).describe('Port number').meta({ title: 'Port' }), + + /** Database (schema) name. Required unless `url` carries it. */ + database: z.string().optional().describe('Database name').meta({ title: 'Database' }), + + /** Authentication user. Passed to `mysql2` as `user`. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), + + /** + * Authentication password. Prefer `external.credentialsRef`; a datasource + * secret always wins over this value. + */ + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), + + /** TLS settings, passed to `mysql2` verbatim. */ + ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: mysqlConfigUnknownKeyError }).strict() + .describe('MySQL / MariaDB connection configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A mysql datasource needs a connection target: set `database` (with `host`/`port`) or ' + + 'a full `url`. Neither was given, so the connection would fall back to the client ' + + 'defaults and silently open a different database than the one intended.', + }); + } + })); + +export type MysqlConfig = z.infer; + +/** JSON-Schema projection of {@link MysqlConfigSchema}, memoized. */ +export const getMysqlConfigJsonSchema = driverConfigJsonSchema(MysqlConfigSchema); diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index 8a52587abd..45315638ae 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -11,8 +11,6 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('localhost'); expect(config.port).toBe(5432); expect(config.schema).toBe('public'); - expect(config.max).toBe(10); - expect(config.min).toBe(0); }); it('should accept config with connection URI', () => { @@ -35,10 +33,6 @@ describe('PostgresConfigSchema', () => { schema: 'app_schema', ssl: true, applicationName: 'objectstack', - max: 50, - min: 5, - idleTimeoutMillis: 60000, - connectionTimeoutMillis: 10000, statementTimeout: 30000, }); @@ -47,10 +41,6 @@ describe('PostgresConfigSchema', () => { expect(config.schema).toBe('app_schema'); expect(config.ssl).toBe(true); expect(config.applicationName).toBe('objectstack'); - expect(config.max).toBe(50); - expect(config.min).toBe(5); - expect(config.idleTimeoutMillis).toBe(60000); - expect(config.connectionTimeoutMillis).toBe(10000); expect(config.statementTimeout).toBe(30000); }); @@ -62,15 +52,11 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('localhost'); expect(config.port).toBe(5432); expect(config.schema).toBe('public'); - expect(config.max).toBe(10); - expect(config.min).toBe(0); expect(config.url).toBeUndefined(); expect(config.username).toBeUndefined(); expect(config.password).toBeUndefined(); expect(config.ssl).toBeUndefined(); expect(config.applicationName).toBeUndefined(); - expect(config.idleTimeoutMillis).toBeUndefined(); - expect(config.connectionTimeoutMillis).toBeUndefined(); expect(config.statementTimeout).toBeUndefined(); }); @@ -83,41 +69,51 @@ describe('PostgresConfigSchema', () => { expect(config.ssl).toBe(false); }); - it('should accept ssl as detailed object', () => { - const config = PostgresConfigSchema.parse({ + // `config.ssl` is the on/off shorthand; certificates live in the + // datasource-level `ssl` block, which #4410 wired through to the client (it + // was declared, strict and read by nobody before that). The narrowing is + // forced by the connection form: it renders anything that is not + // boolean/enum/number as a text input, so a `boolean | object` union here + // would produce a wizard whose every `ssl` value the gate rejects. + it('rejects the certificate-bearing object form, naming where it belongs', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', ssl: { rejectUnauthorized: false, ca: '-----BEGIN CERTIFICATE-----\nMIIB...', - key: '-----BEGIN PRIVATE KEY-----\nMIIE...', - cert: '-----BEGIN CERTIFICATE-----\nMIIC...', }, }); - expect(config.ssl).toBeDefined(); - expect(typeof config.ssl).toBe('object'); - const sslObj = config.ssl as { rejectUnauthorized?: boolean; ca?: string }; - expect(sslObj.rejectUnauthorized).toBe(false); - expect(sslObj.ca).toBeDefined(); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.path.join('.') === 'ssl')).toBe(true); }); - it('should accept ssl object with partial fields', () => { - const config = PostgresConfigSchema.parse({ + it('points a misplaced certificate key at the datasource-level block', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', - ssl: { - rejectUnauthorized: true, - }, + ca: '-----BEGIN CERTIFICATE-----\nMIIB...', }); - const sslObj = config.ssl as { rejectUnauthorized?: boolean }; - expect(sslObj.rejectUnauthorized).toBe(true); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('datasource-level `ssl` block'); }); - it('should reject config without database', () => { + it('should reject a config with no connection target at all', () => { + // Neither `database` nor `url`: the pg client would then open its own + // default (localhost, the OS user's database), so an empty config is a + // datasource pointing somewhere nobody chose. expect(() => PostgresConfigSchema.parse({})).toThrow(); expect(() => PostgresConfigSchema.parse({ host: 'localhost' })).toThrow(); }); + it('accepts a `url` on its own as the connection target', () => { + const config = PostgresConfigSchema.parse({ + url: 'postgresql://user@db.example.com:5432/analytics', + }); + + expect(config.database).toBeUndefined(); + }); + it('should reject config with invalid port type', () => { expect(() => PostgresConfigSchema.parse({ database: 'mydb', @@ -132,11 +128,37 @@ describe('PostgresConfigSchema', () => { })).toThrow(); }); - it('should reject config with invalid max pool type', () => { - expect(() => PostgresConfigSchema.parse({ + it('rejects pool sizing, and says where it belongs', () => { + // `max` / `min` / `idleTimeoutMillis` / `connectionTimeoutMillis` were + // declared here and read by nothing: the factory hardcoded its own pool. The + // datasource-level `pool` block is the one the factory now honours, so the + // rejection relocates rather than merely refusing (#4410). + const result = PostgresConfigSchema.safeParse({ database: 'mydb', - max: 'ten', - })).toThrow(); + max: 100, + min: 10, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`pool: { max: … }`'); + expect(result.error!.issues[0]!.message).toContain('`pool: { min: … }`'); + }); + + it('rejects an unknown key with a rename suggestion', () => { + const result = PostgresConfigSchema.safeParse({ + database: 'mydb', + hostname: 'db.internal', + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`hostname` → `host`'); + }); + + it('rejects `user`, pointing at the canonical `username`', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', user: 'app' }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`user` → `username`'); }); it('should accept config with environment variable patterns', () => { @@ -151,25 +173,10 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('${DB_HOST}'); }); - it('should accept zero as min pool size', () => { - const config = PostgresConfigSchema.parse({ - database: 'mydb', - min: 0, - }); - - expect(config.min).toBe(0); - }); - - it('should accept custom pool configuration', () => { - const config = PostgresConfigSchema.parse({ - database: 'mydb', - max: 100, - min: 10, - idleTimeoutMillis: 120000, - connectionTimeoutMillis: 5000, - }); - - expect(config.max).toBe(100); - expect(config.min).toBe(10); + it('accepts the dev-only autoMigrate passthrough', () => { + expect(PostgresConfigSchema.parse({ database: 'mydb', autoMigrate: 'safe' }).autoMigrate) + .toBe('safe'); + expect(() => PostgresConfigSchema.parse({ database: 'mydb', autoMigrate: 'destructive' })) + .toThrow(); }); }); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 9954e3bd6d..6593cca1b0 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -2,103 +2,148 @@ import { z } from 'zod'; -/** - * PostgreSQL Driver Configuration Schema - * Defines the connection settings specific to PostgreSQL. - */ import { lazySchema } from '../../shared/lazy-schema'; -export const PostgresConfigSchema = lazySchema(() => z.object({ - /** - * Connection URI. - * If provided, it takes precedence over host/port/database. - * Format: postgresql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...] - */ - url: z.string().optional().describe('Connection URI'), - - /** - * Database Name. - */ - database: z.string().describe('Database Name'), - - /** - * Hostname or IP address. - * Defaults to localhost. - */ - host: z.string().default('localhost').describe('Host address'), - - /** - * Port number. - * Defaults to 5432. - */ - port: z.number().default(5432).describe('Port number'), - - /** - * Authentication Username. - */ - username: z.string().optional().describe('Auth User'), - - /** - * Authentication Password. - */ - password: z.string().optional().describe('Auth Password'), +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + DriverSslToggleSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, + SSL_DETAIL_BELONGS_ON_DATASOURCE, +} from './common.zod'; - /** - * Default Schema. - * The schema to use for tables that do not specify a schema. - * Defaults to 'public'. - */ - schema: z.string().default('public').describe('Default Schema'), +/** + * PostgreSQL driver configuration — the `config` slot of a `datasource` whose + * `driver` resolves to `postgres` (`pg` / `postgresql`). + * + * ENFORCED as of #4410: `DatasourceSchema` parses `config` against this schema, + * so a misspelled connection key fails at authoring time instead of leaving the + * datasource on the client's localhost defaults. Every key here is read by + * `createDefaultDatasourceDriverFactory` (→ `SqlDriver`, knex `pg`). + * + * Pool sizing is NOT here: it lives in the driver-agnostic `datasource.pool` + * block, which the factory now honours for every SQL driver. + */ +const POSTGRES_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'ssl', + 'schema', 'applicationName', 'statementTimeout', 'autoMigrate', +] as const; + +/** Prescription for a pool knob written inside `config` instead of `pool`. */ +const poolBelongsOnDatasource = (key: string, canonical: string) => + `\`${key}\` is not driver config — connection pooling is configured once for every driver in ` + + `the datasource's own \`pool\` block. Move it to \`pool: { ${canonical}: … }\`. ` + + `(It was declared here and read by nothing until #4410.)`; + +const postgresConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this postgres datasource's config", + knownKeys: POSTGRES_CONFIG_KEYS, + aliases: { + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + connectionstring: 'url', + dsn: 'url', + uri: 'url', + searchpath: 'schema', + applicationname: 'applicationName', + statementtimeout: 'statementTimeout', + sslmode: 'ssl', + tls: 'ssl', + usessl: 'ssl', + }, + guidance: { + pool: poolBelongsOnDatasource('pool', 'max'), + min: poolBelongsOnDatasource('min', 'min'), + max: poolBelongsOnDatasource('max', 'max'), + idleTimeoutMillis: poolBelongsOnDatasource('idleTimeoutMillis', 'idleTimeoutMillis'), + connectionTimeoutMillis: poolBelongsOnDatasource( + 'connectionTimeoutMillis', + 'connectionTimeoutMillis', + ), + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ca: SSL_DETAIL_BELONGS_ON_DATASOURCE, + cert: SSL_DETAIL_BELONGS_ON_DATASOURCE, + key: SSL_DETAIL_BELONGS_ON_DATASOURCE, + rejectUnauthorized: SSL_DETAIL_BELONGS_ON_DATASOURCE, + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected on the client defaults ' + + "(localhost:5432), which is #4001's original bug one level down.", +}); +export const PostgresConfigSchema = lazySchema(() => z.object({ /** - * Enable SSL/TLS. - * Can be a boolean or an object with specific SSL configuration (ca, cert, key, rejectUnauthorized). + * Connection URI. When present it supersedes `host`/`port`/`database`/ + * `username`, and a datasource secret (`external.credentialsRef`) still + * overrides any password embedded in it. + * Format: `postgresql://[user[:password]@][host][:port][/dbname][?params]` */ - ssl: z.union([ - z.boolean(), - z.object({ - rejectUnauthorized: z.boolean().optional(), - ca: z.string().optional(), - key: z.string().optional(), - cert: z.string().optional(), - }) - ]).optional().describe('Enable SSL'), + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URL' }), - /** - * Application Name. - * Sets the application_name configuration parameter. - */ - applicationName: z.string().optional().describe('Application Name'), + /** Hostname or IP address. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), - /** - * Connection Pool: Max Clients. - * Maximum number of clients the pool should contain. - */ - max: z.number().default(10).describe('Max Pool Size'), + /** Port number. */ + port: z.number().int().default(5432).describe('Port number').meta({ title: 'Port' }), - /** - * Connection Pool: Min Clients. - * Minimum number of clients to keep in the pool. - */ - min: z.number().default(0).describe('Min Pool Size'), + /** Database name. Required unless `url` carries it. */ + database: z.string().optional().describe('Database name').meta({ title: 'Database' }), - /** - * Idle Timeout (ms). - * The number of milliseconds a client must sit idle in the pool and not be checked out - * before it is disconnected from the backend and discarded. - */ - idleTimeoutMillis: z.number().optional().describe('Idle Timeout (ms)'), + /** Authentication user. Passed to `pg` as `user`. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), /** - * Connection Timeout (ms). - * The number of milliseconds to wait before timing out when connecting a new client. - */ - connectionTimeoutMillis: z.number().optional().describe('Connection Timeout (ms)'), - - /** - * Statement Timeout (ms). - * Abort any statement that takes more than the specified number of milliseconds. + * Authentication password. Prefer `external.credentialsRef` — a secret-store + * reference — or an environment placeholder; a datasource secret always wins + * over this value. */ - statementTimeout: z.number().optional().describe('Statement Timeout (ms)'), -})); + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), + + /** TLS settings, passed to `pg` verbatim. */ + ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), + + /** Default schema for tables that do not name one — knex `searchPath`. */ + schema: z.string().default('public').describe('Default schema (knex searchPath)') + .meta({ title: 'Schema' }), + + /** `application_name` on the connection — how this stack shows up in `pg_stat_activity`. */ + applicationName: z.string().optional().describe('Postgres application_name') + .meta({ title: 'Application name' }), + + /** `statement_timeout` in milliseconds — aborts any statement that runs longer. */ + statementTimeout: z.number().int().positive().optional() + .describe('Abort statements running longer than this (ms)') + .meta({ title: 'Statement timeout (ms)' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: postgresConfigUnknownKeyError }).strict() + .describe('PostgreSQL connection configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A postgres datasource needs a connection target: set `database` (with `host`/`port`) ' + + 'or a full `url`. Neither was given, so the connection would fall back to the client ' + + 'defaults and silently open a different database than the one intended.', + }); + } + })); export type PostgresConfig = z.infer; + +/** JSON-Schema projection of {@link PostgresConfigSchema}, memoized. */ +export const getPostgresConfigJsonSchema = driverConfigJsonSchema(PostgresConfigSchema); diff --git a/packages/spec/src/data/driver/sqlite.zod.ts b/packages/spec/src/data/driver/sqlite.zod.ts new file mode 100644 index 0000000000..97b27b4604 --- /dev/null +++ b/packages/spec/src/data/driver/sqlite.zod.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, +} from './common.zod'; + +/** + * SQLite driver configuration — the `config` slot of a `datasource` whose + * `driver` resolves to `sqlite` (native `better-sqlite3`, with the dev-only + * step-down to wasm then in-memory, #2229) or to `sqlite-wasm` (pure-JS). + * + * The one key that matters is `filename`, and it is exactly the key the silent + * strip used to hide: an author who wrote `path:` got no error, the connection + * fell back to `:memory:`, and their data vanished on restart with every signal + * saying the datasource was configured. + * + * `file` and `database` are a different case — the factory reads them as + * undeclared `??` fallbacks, so they happened to work while being documented + * nowhere. They are named as renames here rather than blessed: one strict + * contract beats a spelling that works only because a reader is lenient + * (AGENTS.md Prime Directive #12). The factory keeps its tolerance for records + * already persisted that way; no new one can be authored. + */ +const SQLITE_CONFIG_KEYS = ['filename', 'autoMigrate'] as const; + +const FILENAME_ALIASES = { + file: 'filename', + filepath: 'filename', + path: 'filename', + database: 'filename', + db: 'filename', + url: 'filename', + connectionstring: 'filename', +} as const; + +const sqliteHistory = + 'Until #4410 nothing validated `datasource.config` at all — a misspelled `filename` was ' + + 'accepted in silence and the database silently became an ephemeral `:memory:` one, so the ' + + 'data was gone on the next boot with nothing having reported a problem.'; + +const IN_MEMORY_GUIDANCE = + '`memory` is not a sqlite key. An ephemeral database is `filename: \':memory:\'`; for the ' + + 'mingo in-memory engine (a different driver entirely) set `driver: \'memory\'`.'; + +const sqliteConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this sqlite datasource's config", + knownKeys: SQLITE_CONFIG_KEYS, + aliases: FILENAME_ALIASES, + guidance: { + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + memory: IN_MEMORY_GUIDANCE, + persist: + '`persist` is a `sqlite-wasm` key — the native sqlite driver writes through on every ' + + "statement and has nothing to schedule. Set `driver: 'sqlite-wasm'` to use it.", + }, + history: sqliteHistory, +}); + +export const SqliteConfigSchema = lazySchema(() => z.object({ + /** + * Database file path, or `:memory:` for an ephemeral in-process database. + * A relative path resolves against the server's working directory. + */ + filename: z.string().default(':memory:') + .describe('Database file path, or ":memory:" for an ephemeral database') + .meta({ title: 'Filename' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: sqliteConfigUnknownKeyError }).strict() + .describe('SQLite connection configuration')); + +export type SqliteConfig = z.infer; + +/** JSON-Schema projection of {@link SqliteConfigSchema}, memoized. */ +export const getSqliteConfigJsonSchema = driverConfigJsonSchema(SqliteConfigSchema); + +/** + * When a file-backed wasm database is flushed back to disk. `debounced:` + * batches writes; `:memory:` databases ignore this entirely. + */ +export const SqliteWasmPersistModeSchema = z.union([ + z.literal('on-disconnect'), + z.literal('on-write'), + z.string().regex(/^debounced:\d+$/, 'Expected `debounced:`'), +]).describe('When to flush a file-backed wasm database to disk'); + +export type SqliteWasmPersistMode = z.infer; + +const SQLITE_WASM_CONFIG_KEYS = ['filename', 'persist'] as const; + +const sqliteWasmConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this sqlite-wasm datasource's config", + knownKeys: SQLITE_WASM_CONFIG_KEYS, + aliases: FILENAME_ALIASES, + guidance: { + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + memory: IN_MEMORY_GUIDANCE, + autoMigrate: + '`autoMigrate` is honoured by the native sqlite / postgres / mysql drivers only — the ' + + 'wasm driver is constructed without it, so writing it here would change nothing.', + }, + history: sqliteHistory, +}); + +export const SqliteWasmConfigSchema = lazySchema(() => z.object({ + /** + * Database file path, or `:memory:` for an ephemeral in-process database. + * A file-backed wasm database persists according to {@link SqliteWasmPersistModeSchema}. + */ + filename: z.string().default(':memory:') + .describe('Database file path, or ":memory:" for an ephemeral database') + .meta({ title: 'Filename' }), + + /** + * Flush policy for a file-backed database. Defaults to `on-write` when a + * filename is given; `:memory:` never persists. + */ + persist: SqliteWasmPersistModeSchema.optional().meta({ title: 'Persist mode' }), +}, { error: sqliteWasmConfigUnknownKeyError }).strict() + .describe('SQLite (WASM) connection configuration')); + +export type SqliteWasmConfig = z.infer; + +/** JSON-Schema projection of {@link SqliteWasmConfigSchema}, memoized. */ +export const getSqliteWasmConfigJsonSchema = driverConfigJsonSchema(SqliteWasmConfigSchema); diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index ff5344b8c5..541b4de3b6 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -27,6 +27,7 @@ import { MULTI_CAPABLE_TYPES, isMultiValueField, valueSchemaFor, + referenceTargetOf, } from './field-value.zod'; const ok = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') => @@ -48,6 +49,44 @@ describe('semantic type classes', () => { } }); + it('`referenceTargetOf` reads an author-written target, and the implied one for `user`', () => { + // The author-chosen half. + expect(referenceTargetOf({ type: 'lookup', reference: 'accounts' })).toBe('accounts'); + expect(referenceTargetOf({ type: 'master_detail', reference: 'orders' })).toBe('orders'); + expect(referenceTargetOf({ type: 'tree', reference: 'categories' })).toBe('categories'); + + // `user`'s target is a CONSTANT OF THE TYPE: `Field.user()` takes no target + // argument and writes `reference: 'sys_user'` itself, so a field authored + // without it is fully specified, not under-specified (cloud#983). + expect(referenceTargetOf({ type: 'user' })).toBe('sys_user'); + expect(referenceTargetOf({ type: 'user', reference: 'sys_user' })).toBe('sys_user'); + // An explicit target still wins — nothing here overrides authored metadata. + expect(referenceTargetOf({ type: 'user', reference: 'my_people' })).toBe('my_people'); + + // Genuinely targetless: the types whose target IS author-chosen, unwritten. + expect(referenceTargetOf({ type: 'lookup' })).toBeUndefined(); + expect(referenceTargetOf({ type: 'master_detail' })).toBeUndefined(); + expect(referenceTargetOf({ type: 'tree' })).toBeUndefined(); + // Not a reference type at all, and non-field inputs. + expect(referenceTargetOf({ type: 'text', reference: 'accounts' })).toBeUndefined(); + expect(referenceTargetOf(undefined)).toBeUndefined(); + expect(referenceTargetOf('user')).toBeUndefined(); + }); + + it('every reference type either implies a target or admits one — no third state', () => { + // Guards the set from drifting: adding a reference type without deciding + // which half it belongs to would leave `referenceTargetOf` silently + // answering `undefined` for a fully-authored field. + for (const t of REFERENCE_VALUE_TYPES) { + const implied = referenceTargetOf({ type: t }); + const authored = referenceTargetOf({ type: t, reference: 'somewhere' }); + expect(authored, `authored target for ${t}`).toBe('somewhere'); + expect(implied === undefined || typeof implied === 'string', `implied target for ${t}`).toBe(true); + } + expect([...REFERENCE_VALUE_TYPES].filter((t) => referenceTargetOf({ type: t }) !== undefined)) + .toEqual(['user']); + }); + it('every FieldType lands in at least one value class (no unclassified types)', () => { const classified = new Set([ ...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES, @@ -154,6 +193,33 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => { ok({ type: 'lookup' }, 'acc_1', 'expanded'); // unresolvable ids stay ids }); + it('#4455: a SERIALIZED embedded record is not an id, in either form', () => { + // The shape the ADR-0104 D1 scan's own header names — "a `lookup` holding + // an expanded record object" — as it actually reaches a SQL deployment: as + // JSON text in a TEXT column. `z.string().min(1)` accepted it, so the scan + // reported clean on the one case it exists to find. + for (const type of ['lookup', 'master_detail', 'user', 'tree']) { + bad({ type }, '{"id":"acc_1","name":"embedded"}'); + bad({ type }, ' {"id":"acc_1"}'); // padded — same value, still not an id + bad({ type }, '[{"id":"acc_1"}]'); // the multi-value flavour + // …and the expanded read form must not launder it either: `$expand` + // produces an OBJECT, never its serialization. + bad({ type }, '{"id":"acc_1","name":"embedded"}', 'expanded'); + } + bad({ type: 'lookup', multiple: true }, ['acc_1', '{"id":"acc_2"}']); + + // Narrow on purpose: the rejection is "this is an embedded record", not an + // id alphabet. A reference id is whatever the target object's key holds — + // including an external key an ADR-0015 federated datasource supplies — so + // every one of these stays valid. + ok({ type: 'lookup' }, 'acc_synthetic_0001'); + ok({ type: 'lookup' }, '0e2f4c1a-9b7d-4e3f-8a1b-2c3d4e5f6a7b'); + ok({ type: 'lookup' }, 'CB0-2026-0001'); + ok({ type: 'lookup' }, 'SFDC:001xx000003DGb2AAG'); // external key, punctuated + ok({ type: 'lookup' }, 'ops/eu-west/tenant-7'); // and pathy + ok({ type: 'user' }, 'usr_system'); + }); + it('D3 wave 2: the STORED media form is an opaque sys_file id', () => { ok({ type: 'file' }, 'file_01HXYZ'); ok({ type: 'file' }, '0e2f4c1a-9b7d-4e3f-8a1b-2c3d4e5f6a7b'); diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index edf155452d..175c64e93a 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -32,6 +32,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { SystemObjectName } from '../system/constants/system-names'; import type { FieldType } from './field.zod'; import { AddressSchema } from './field.zod'; @@ -93,6 +94,50 @@ export const REFERENCE_VALUE_TYPES: ReadonlySet = new Set([ 'lookup', 'master_detail', 'user', 'tree', ] as const satisfies readonly FieldType[]); +/** + * Reference types whose target object is FIXED BY THE TYPE rather than chosen + * by the author, mapped to that target. + * + * `user` is the only member: `field.zod` defines it as "a lookup specialized to + * the `sys_user` system object … target fixed to the `sys_user` system object", + * and the `Field.user()` builder — unlike `Field.lookup(reference, …)` / + * `Field.masterDetail(reference, …)` — takes NO target argument and writes + * `reference: 'sys_user'` itself. The target is a CONSTANT OF THE TYPE, so + * `reference` on a `user` field materializes that constant; it does not supply + * it. Metadata authored without it (hand-written JSON, an AI author, a Studio + * form) is fully specified, not under-specified. + */ +const IMPLICIT_REFERENCE_TARGETS: ReadonlyMap = new Map([ + ['user', SystemObjectName.USER], +]); + +/** + * The object a reference-typed field points at — the SINGLE arbiter of "what + * does this field expand into", for the gate that admits an `expand` and the + * engine that performs it alike. + * + * Returns `undefined` only when the field genuinely names no target: a + * non-reference type, or a `lookup`/`master_detail`/`tree` with no `reference` + * (an authoring bug — those types carry an author-chosen target and nothing + * can supply it for them). + * + * Framework#4443 / cloud#983: the two callers used to read `field.reference` + * raw, which made a `{ type: 'user' }` field targetless to BOTH — the expand + * gate refused `?expand=` with `400 INVALID_FIELD … declares no + * target object`, so an AI-authored app whose default list view expanded its + * "responsible person" column answered its very first screen with an error + * page. Deriving the target here (rather than requiring every author to + * restate a constant) is what keeps the gate and the engine agreeing on the + * one question they both ask. + */ +export function referenceTargetOf(def: unknown): string | undefined { + if (!def || typeof def !== 'object') return undefined; + const { type, reference } = def as { type?: unknown; reference?: unknown }; + if (typeof type !== 'string' || !REFERENCE_VALUE_TYPES.has(type)) return undefined; + if (typeof reference === 'string' && reference) return reference; + return IMPLICIT_REFERENCE_TARGETS.get(type); +} + /** * Media/attachment types. Stored form TODAY is the legacy inline metadata * object (`{url, name?, size?, ...}`) or an opaque file-id/url string; @@ -267,8 +312,47 @@ export const FileLikeValueSchema = lazySchema(() => z.union([ FileValueSchema, ])); -/** Record-id string — the stored form of every reference type. */ -export const ReferenceIdValueSchema = lazySchema(() => z.string().min(1)); +/** + * A stored reference value that is really an EMBEDDED RECORD, serialized. + * + * In a document store the expanded form arrives as an object and `z.string()` + * already rejects it. In a SQL deployment the same value reaches storage as + * JSON *text* in a TEXT column — a non-empty string — which is exactly how a + * legacy embedded reference survives into a relational table. Anchored on the + * first non-space character rather than a `JSON.parse` attempt so the check + * stays allocation-free on the write path: no record id the platform mints, and + * no external key any datasource can supply, begins with `{` or `[`. + */ +const EMBEDDED_REFERENCE_TEXT = /^\s*[[{]/; + +/** + * Record-id string — the stored form of every reference type. + * + * Non-empty is not the whole contract. `os migrate value-shapes` is the + * evidence half of the ADR-0104 D1 per-deployment gate, and its own header + * names "a `lookup` holding an expanded record object" as a case it exists to + * find — but a bare `z.string().min(1)` accepts the JSON text such a value is + * stored as, so the gate closed on evidence it never collected (#4455). The + * scan deliberately imports the write-path predicate, so the write path was + * equally blind and the value survived future writes too. + * + * The rejection is deliberately NARROW — an embedded object/array, not an id + * charset. Its file sibling {@link FileReferenceIdValueSchema} can bound its + * charset because a `sys_file` id is minted by the platform and by nothing + * else; a reference id is whatever the target object's primary key holds, + * including an external key an ADR-0015 federated datasource supplies. So this + * rejects the shape that is provably not an id and leaves the id alphabet to + * the object that owns it. Widening it further needs evidence about real + * external keys, not a guess. + */ +export const ReferenceIdValueSchema = lazySchema(() => + z.string().min(1).refine((v) => !EMBEDDED_REFERENCE_TEXT.test(v), { + message: + 'Expected a record id, but the value is an embedded record object. A reference stores an ' + + 'opaque id; the expanded record is the READ shape ($expand produces it) and is never stored. ' + + 'Replace the value with the referenced record\'s id.', + }), +); function optionCodes(def: ValueShapeFieldDef): string[] { if (!Array.isArray(def.options)) return []; diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index d1a5438884..eba3cae85a 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -9,6 +9,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { HookBodySchema } from './hook-body.zod'; /* @@ -276,6 +277,14 @@ export const HookSchema = lazySchema(() => z.object({ * - log: Log error and continue */ onError: z.enum(['abort', 'log']).default('abort').describe('Error handling strategy'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // MISSING until the registered-type invariant test was written: `hook` closed + // strict in the #4001 data step without declaring it, so the `_packageId` / + // `_provenance` that `MetadataPlugin` stamps on every registered type were + // REJECTED here — the same live 422 that `permission` hit on the ADR-0094 + // overlay path before Tier-A declared them (#4001 findings log, entries 2/8). + ...MetadataProtectionFields, }, { error: hookUnknownKeyError }).strict()); /** diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index a1b776087a..d4b12dae6c 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -60,6 +60,12 @@ export * from './document.zod'; export * from './external-lookup.zod'; export * from './datasource.zod'; +// Per-driver `datasource.config` contracts (#4410) — the enforcement half of +// the `config` escape hatch DatasourceSchema leaves open at the top level. +// Exported because they are now load-bearing; nothing could import them while +// they were merely the shapes authors were TOLD to write against. +export * from './driver/index'; + // External Datasource Federation — SQL↔field type compatibility (ADR-0015) export * from './type-compat'; export * from './external-catalog.zod'; diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts new file mode 100644 index 0000000000..8011bee622 --- /dev/null +++ b/packages/spec/src/data/search-fields.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + resolveSearchFieldResolution, + resolveSearchFields, + SEARCH_AUTO_EXCLUDED_FIELDS, +} from './search-fields'; + +// --------------------------------------------------------------------------- +// [#4483] The auto-default's "lead" field ORDERS the set; it must not ADMIT one. +// +// `autoDefaultFields` filters every field through three exclusions, then used to +// prepend the display/name/title field on an EXISTENCE check alone — so the +// exclusions did not hold for whichever field happened to lead. The regression +// this pins is not hypothetical: ADR-0079 designates `nameField` at +// registration, and on a table whose only textual column is the primary key it +// designates `id`, turning `$search` into a substring scan over the PK. +// --------------------------------------------------------------------------- +describe('[#4483] $search auto field set — lead orders, never admits', () => { + const pkOnly = { + id: { type: 'text' }, + amount: { type: 'number' }, + }; + + it('excludes `id` with no display field (the already-correct baseline)', () => { + expect(resolveSearchFieldResolution({ fields: pkOnly })).toEqual({ + allowed: [], + source: 'auto', + }); + }); + + it('a displayField on the exclusion list does NOT re-enter the set', () => { + // Pre-#4483 this returned `{ allowed: ['id'] }`. + expect(resolveSearchFieldResolution({ fields: pkOnly, displayField: 'id' })).toEqual({ + allowed: [], + source: 'auto', + }); + }); + + it('every SEARCH_AUTO_EXCLUDED_FIELDS member stays out even as displayField', () => { + for (const excluded of SEARCH_AUTO_EXCLUDED_FIELDS) { + const { allowed } = resolveSearchFieldResolution({ + fields: { [excluded]: { type: 'text' }, title: { type: 'text' } }, + displayField: excluded, + }); + expect(allowed, `'${excluded}' leaked into the auto set as displayField`).toEqual(['title']); + } + }); + + it('a hidden display field does not lead and does not enter', () => { + const { allowed } = resolveSearchFieldResolution({ + fields: { name: { type: 'text', hidden: true }, subject: { type: 'text' } }, + displayField: 'name', + }); + expect(allowed).toEqual(['subject']); + }); + + it('a display field of an unsearchable TYPE does not enter', () => { + const { allowed } = resolveSearchFieldResolution({ + fields: { avatar: { type: 'image' }, subject: { type: 'text' } }, + displayField: 'avatar', + }); + expect(allowed).toEqual(['subject']); + }); + + it('the `name` / `title` bypasses are gated by the same predicate', () => { + // Both exist but are unsearchable — neither may lead nor enter. + const { allowed } = resolveSearchFieldResolution({ + fields: { + name: { type: 'json' }, + title: { type: 'vector' }, + subject: { type: 'text' }, + }, + }); + expect(allowed).toEqual(['subject']); + }); + + it('an ELIGIBLE display field still leads — the ordering intent is intact', () => { + const { allowed } = resolveSearchFieldResolution({ + fields: { + code: { type: 'text' }, + subject: { type: 'text' }, + stage: { type: 'select' }, + }, + displayField: 'subject', + }); + expect(allowed[0]).toBe('subject'); + expect(new Set(allowed)).toEqual(new Set(['subject', 'code', 'stage'])); + }); + + it('falls back to `name`, then `title`, for the lead position', () => { + expect( + resolveSearchFieldResolution({ + fields: { code: { type: 'text' }, name: { type: 'text' } }, + }).allowed[0], + ).toBe('name'); + expect( + resolveSearchFieldResolution({ + fields: { code: { type: 'text' }, title: { type: 'text' } }, + }).allowed[0], + ).toBe('title'); + }); + + it('a declared `searchableFields` list is unaffected by the lead rule', () => { + // `declared` is the author's explicit choice and bypasses the auto-default + // entirely — including its exclusions. Pinned so the fix is not read as + // narrowing the declared path too. + expect( + resolveSearchFieldResolution({ fields: pkOnly, searchableFields: ['id'], displayField: 'id' }), + ).toEqual({ allowed: ['id'], source: 'declared' }); + }); + + it('the #4254 ingress gate no longer admits `$searchFields=id`', () => { + // `resolveSearchFields` intersects the override with `allowed`; with `id` + // out of `allowed` the override matches nothing and cannot widen the scan. + expect(resolveSearchFields({ fields: pkOnly, displayField: 'id', requestedFields: 'id' })) + .toEqual([]); + }); +}); diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index fcd63f10de..982d8aecb5 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -72,10 +72,32 @@ function autoDefaultFields(fields: Record, displayField if (SEARCH_AUTO_EXCLUDED_TYPES.has(t)) return false; return SEARCHABLE_TEXTUAL_TYPES.has(t) || SEARCHABLE_ENUM_TYPES.has(t); }); - // Lead with the display/name field when present. - const lead = displayField && fields[displayField] ? displayField - : fields.name ? 'name' - : fields.title ? 'title' + // Lead with the display/name field — ORDERING ONLY (#4483). + // + // `lead` used to be picked by EXISTENCE (`fields[displayField]`), then + // prepended unconditionally, so it re-entered the set after the three + // exclusions above had already rejected it. That made + // `SEARCH_AUTO_EXCLUDED_FIELDS` — whose contract is "never auto-included" — + // untrue for whichever field happened to be the display field, and the case + // is not contrived: ADR-0079's `provisionPrimary(schema, { synthesize: false })` + // designates `nameField` at registration, and on a table whose only textual + // column IS the primary key (system tables, junction tables, append-only + // logs) it designates `id`. `$search` then expanded to + // `{ id: { $contains: } }` — a substring scan over the primary key. + // + // It also loosened the #4254 REST ingress gate one layer up, which asks this + // same resolution whether a `$searchFields` override names a field the engine + // would actually scan: with `id` in `allowed`, `$searchFields=id` was + // ACCEPTED instead of refused. + // + // The lead's job is to put the primary title FIRST, never to admit it, so it + // is now chosen from `names` — the already-filtered set. A display field that + // is excluded, hidden or of an unsearchable type simply does not lead, and + // the set is unchanged. + const eligible = (f: string | undefined): f is string => !!f && names.includes(f); + const lead = eligible(displayField) ? displayField + : eligible('name') ? 'name' + : eligible('title') ? 'title' : undefined; if (!lead) return names; return [lead, ...names.filter((f) => f !== lead)]; diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 79c9519544..60abb9eb62 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -7,6 +7,8 @@ import { z } from 'zod'; * Defines how the engine handles existing records when a seed is applied. */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const SeedMode = z.enum([ 'insert', // Try to insert, fail on duplicate 'update', // Only update found records, ignore new @@ -28,7 +30,26 @@ export const SeedMode = z.enum([ * its rows load when the draft is published. Named `Seed` (not `Dataset`) so * the `dataset` name stays reserved for the ADR-0021 analytics semantic layer. */ -export const SeedSchema = lazySchema(() => z.object({ +export const SeedSchema = lazySchema(() => strictObject({ + surface: 'this seed', + history: + 'Until #4001 these were dropped silently — the seed still applied, on the defaults ' + + '(`mode: upsert`, `externalId: name`) rather than what was written.', + aliases: { + objectname: 'object', + target: 'object', + data: 'records', + rows: 'records', + values: 'records', + key: 'externalId', + externalkey: 'externalId', + naturalkey: 'externalId', + strategy: 'mode', + conflict: 'mode', + environment: 'env', + environments: 'env', + }, +}, { /** * Target Object * The machine name of the object to populate. @@ -73,6 +94,16 @@ export const SeedSchema = lazySchema(() => z.object({ * Array of raw JSON objects matching the Object Schema. */ records: z.array(z.record(z.string(), z.unknown())).describe('Data records'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `seed` is a registered metadata type, so `MetadataPlugin`'s artifact loader + // stamps `_packageId` / `_provenance` on it like every sibling, and + // `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying them. + // Undeclared, those were stripped on every parse — the same inverse drift + // that made `permission` 422 on the ADR-0094 overlay path when it went strict + // (#4001 findings log, entry 2). Declared here so closing the shape cannot + // repeat it. + ...MetadataProtectionFields, })); /** Parsed/output type — all defaults are applied (env, mode, externalId always present) */ diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 2e5821c6a1..988ce9124d 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -70,23 +70,18 @@ import { FieldMappingSchema as BaseFieldMappingSchema } from '../shared/mapping. * @see {@link file://../automation/sync.zod.ts} for Level 1 (simple sync) * @see {@link file://../automation/etl.zod.ts} for Level 2 (data engineering) * - * ## When to use Integration Connector vs. Trigger Registry? - * - * **Use `integration/connector.zod.ts` when:** - * - Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) - * - Complex OAuth2/SAML authentication required - * - Bidirectional sync with field mapping and transformations - * - Webhook management and rate limiting required - * - Full CRUD operations and data synchronization - * - Need comprehensive retry strategies and error handling - * - * **Use `automation/trigger-registry.zod.ts` when:** - * - Building simple automation triggers (e.g., "when Slack message received, create task") - * - No complex authentication needed (simple API keys, basic auth) - * - Lightweight, single-purpose integrations - * - Quick setup with minimal configuration - * - * @see ../../automation/trigger-registry.zod.ts for lightweight automation triggers + * ## There is no "Trigger Registry" alternative + * + * This header used to carry a "When to use Integration Connector vs. Trigger + * Registry?" comparison, steering "lightweight" cases to + * `automation/trigger-registry.zod.ts`. That file was a third declaration of + * the connector vocabulary with zero consumers — nothing registered, validated + * or executed against it — so the guidance pointed authors, with the + * platform's authority, at a dead end (#4499; removed alongside the #4480 + * per-provider template cluster). The same defect class as the + * `capabilities.readOnly` prescription #4487 corrected: a signpost must land + * somewhere enforced. Lightweight cases are served HERE — a connector instance + * with simple `auth` — or by `automation/sync.zod.ts` / `etl.zod.ts` below. */ // ============================================================================ diff --git a/packages/spec/src/integration/connector/database.test.ts b/packages/spec/src/integration/connector/database.test.ts deleted file mode 100644 index c97d106252..0000000000 --- a/packages/spec/src/integration/connector/database.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - DatabaseProviderSchema, - DatabasePoolConfigSchema, - SslConfigSchema, - CdcConfigSchema, - DatabaseTableSchema, - DatabaseConnectorSchema, -} from './database.zod'; - -const baseAuth = { type: 'none' as const }; - -const minimalTable = { - name: 'customer', - label: 'Customer', - tableName: 'customers', - primaryKey: 'id', -}; - -const minimalConnector = { - name: 'pg_main', - label: 'PostgreSQL Main', - type: 'database' as const, - provider: 'postgresql' as const, - authentication: baseAuth, - connectionConfig: { - host: 'localhost', - port: 5432, - database: 'mydb', - username: 'user', - password: 'pass', - }, - tables: [minimalTable], -}; - -describe('DatabaseProviderSchema', () => { - it('should accept all valid providers', () => { - const providers = ['postgresql', 'mysql', 'mariadb', 'mssql', 'oracle', 'mongodb', 'redis', 'cassandra', 'snowflake', 'bigquery', 'redshift', 'custom']; - for (const p of providers) { - expect(DatabaseProviderSchema.parse(p)).toBe(p); - } - }); - - it('should reject invalid provider', () => { - expect(() => DatabaseProviderSchema.parse('sqlite')).toThrow(); - }); -}); - -describe('DatabasePoolConfigSchema', () => { - it('should apply defaults', () => { - const result = DatabasePoolConfigSchema.parse({}); - expect(result.min).toBe(2); - expect(result.max).toBe(10); - expect(result.idleTimeoutMs).toBe(30000); - expect(result.testOnBorrow).toBe(true); - }); - - it('should accept custom values', () => { - const result = DatabasePoolConfigSchema.parse({ min: 0, max: 50, idleTimeoutMs: 5000 }); - expect(result.min).toBe(0); - expect(result.max).toBe(50); - }); - - it('should reject max below 1', () => { - expect(() => DatabasePoolConfigSchema.parse({ max: 0 })).toThrow(); - }); - - it('should reject idleTimeoutMs below 1000', () => { - expect(() => DatabasePoolConfigSchema.parse({ idleTimeoutMs: 500 })).toThrow(); - }); -}); - -describe('SslConfigSchema', () => { - it('should apply defaults', () => { - const result = SslConfigSchema.parse({}); - expect(result.enabled).toBe(false); - expect(result.rejectUnauthorized).toBe(true); - }); - - it('should accept full config', () => { - const result = SslConfigSchema.parse({ - enabled: true, - rejectUnauthorized: false, - ca: 'ca-cert', - cert: 'client-cert', - key: 'client-key', - }); - expect(result.enabled).toBe(true); - expect(result.ca).toBe('ca-cert'); - }); -}); - -describe('CdcConfigSchema', () => { - it('should accept valid CDC config', () => { - const result = CdcConfigSchema.parse({ method: 'log_based' }); - expect(result.enabled).toBe(false); - expect(result.batchSize).toBe(1000); - expect(result.pollIntervalMs).toBe(1000); - }); - - it('should accept all CDC methods', () => { - for (const m of ['log_based', 'trigger_based', 'query_based', 'custom']) { - expect(() => CdcConfigSchema.parse({ method: m })).not.toThrow(); - } - }); - - it('should reject missing method', () => { - expect(() => CdcConfigSchema.parse({ enabled: true })).toThrow(); - }); - - it('should reject batchSize out of range', () => { - expect(() => CdcConfigSchema.parse({ method: 'log_based', batchSize: 0 })).toThrow(); - expect(() => CdcConfigSchema.parse({ method: 'log_based', batchSize: 10001 })).toThrow(); - }); - - it('should accept optional fields', () => { - const result = CdcConfigSchema.parse({ - method: 'log_based', - enabled: true, - slotName: 'slot1', - publicationName: 'pub1', - startPosition: '0/1234', - }); - expect(result.slotName).toBe('slot1'); - }); -}); - -describe('DatabaseTableSchema', () => { - it('should accept valid table', () => { - const result = DatabaseTableSchema.parse(minimalTable); - expect(result.enabled).toBe(true); - }); - - it('should accept table with all optional fields', () => { - const data = { - ...minimalTable, - schema: 'public', - enabled: false, - fieldMappings: [{ source: 'ext_id', target: 'id' }], - whereClause: 'status = \'active\'', - }; - expect(() => DatabaseTableSchema.parse(data)).not.toThrow(); - }); - - it('should reject non-snake_case name', () => { - expect(() => DatabaseTableSchema.parse({ ...minimalTable, name: 'Customer' })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => DatabaseTableSchema.parse({ name: 'tbl' })).toThrow(); - }); -}); - -describe('DatabaseConnectorSchema', () => { - it('should accept minimal valid connector', () => { - expect(() => DatabaseConnectorSchema.parse(minimalConnector)).not.toThrow(); - }); - - it('should apply defaults', () => { - const result = DatabaseConnectorSchema.parse(minimalConnector); - expect(result.queryTimeoutMs).toBe(30000); - expect(result.enableQueryLogging).toBe(false); - expect(result.enabled).toBe(true); - }); - - it('should accept full connector', () => { - const full = { - ...minimalConnector, - poolConfig: { min: 5, max: 25 }, - sslConfig: { enabled: true }, - cdcConfig: { method: 'log_based', enabled: true }, - readReplicaConfig: { - enabled: true, - hosts: [{ host: 'replica1', port: 5432, weight: 0.5 }], - }, - queryTimeoutMs: 60000, - enableQueryLogging: true, - }; - expect(() => DatabaseConnectorSchema.parse(full)).not.toThrow(); - }); - - it('should reject wrong type literal', () => { - expect(() => DatabaseConnectorSchema.parse({ ...minimalConnector, type: 'saas' })).toThrow(); - }); - - it('should reject invalid port', () => { - expect(() => DatabaseConnectorSchema.parse({ - ...minimalConnector, - connectionConfig: { ...minimalConnector.connectionConfig, port: 0 }, - })).toThrow(); - expect(() => DatabaseConnectorSchema.parse({ - ...minimalConnector, - connectionConfig: { ...minimalConnector.connectionConfig, port: 70000 }, - })).toThrow(); - }); - - it('should reject queryTimeoutMs out of range', () => { - expect(() => DatabaseConnectorSchema.parse({ ...minimalConnector, queryTimeoutMs: 500 })).toThrow(); - expect(() => DatabaseConnectorSchema.parse({ ...minimalConnector, queryTimeoutMs: 500000 })).toThrow(); - }); - - it('should reject missing tables', () => { - const { tables: _, ...noTables } = minimalConnector; - expect(() => DatabaseConnectorSchema.parse(noTables)).toThrow(); - }); - - it('should reject read replica with invalid weight', () => { - expect(() => DatabaseConnectorSchema.parse({ - ...minimalConnector, - readReplicaConfig: { - enabled: true, - hosts: [{ host: 'r1', port: 5432, weight: 2 }], - }, - })).toThrow(); - }); -}); diff --git a/packages/spec/src/integration/connector/database.zod.ts b/packages/spec/src/integration/connector/database.zod.ts deleted file mode 100644 index a038a7e21e..0000000000 --- a/packages/spec/src/integration/connector/database.zod.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { - ConnectorSchema, - FieldMappingSchema, -} from '../connector.zod'; - -/** - * Database Connector Protocol Template - * - * Specialized connector for database systems (PostgreSQL, MySQL, SQL Server, etc.) - * Extends the base connector with database-specific features like schema discovery, - * CDC (Change Data Capture), and connection pooling. - */ - -/** - * Database Provider Types - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const DatabaseProviderSchema = lazySchema(() => z.enum([ - 'postgresql', - 'mysql', - 'mariadb', - 'mssql', - 'oracle', - 'mongodb', - 'redis', - 'cassandra', - 'snowflake', - 'bigquery', - 'redshift', - 'custom', -]).describe('Database provider type')); - -export type DatabaseProvider = z.infer; - -/** - * Database Connection Pool Configuration - */ -export const DatabasePoolConfigSchema = lazySchema(() => z.object({ - min: z.number().min(0).default(2).describe('Minimum connections in pool'), - max: z.number().min(1).default(10).describe('Maximum connections in pool'), - idleTimeoutMs: z.number().min(1000).default(30000).describe('Idle connection timeout in ms'), - connectionTimeoutMs: z.number().min(1000).default(10000).describe('Connection establishment timeout in ms'), - acquireTimeoutMs: z.number().min(1000).default(30000).describe('Connection acquisition timeout in ms'), - evictionRunIntervalMs: z.number().min(1000).default(30000).describe('Connection eviction check interval in ms'), - testOnBorrow: z.boolean().default(true).describe('Test connection before use'), -})); - -export type DatabasePoolConfig = z.infer; - -/** - * SSL/TLS Configuration - */ -export const SslConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable SSL/TLS'), - rejectUnauthorized: z.boolean().default(true).describe('Reject unauthorized certificates'), - ca: z.string().optional().describe('Certificate Authority certificate'), - cert: z.string().optional().describe('Client certificate'), - key: z.string().optional().describe('Client private key'), -})); - -export type SslConfig = z.infer; - -/** - * Change Data Capture (CDC) Configuration - */ -export const CdcConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable CDC'), - - method: z.enum([ - 'log_based', // Transaction log parsing (e.g., PostgreSQL logical replication) - 'trigger_based', // Database triggers for change tracking - 'query_based', // Timestamp-based queries - 'custom', // Custom CDC implementation - ]).describe('CDC method'), - - slotName: z.string().optional().describe('Replication slot name (for log-based CDC)'), - - publicationName: z.string().optional().describe('Publication name (for PostgreSQL)'), - - startPosition: z.string().optional().describe('Starting position/LSN for CDC stream'), - - batchSize: z.number().min(1).max(10000).default(1000).describe('CDC batch size'), - - pollIntervalMs: z.number().min(100).default(1000).describe('CDC polling interval in ms'), -})); - -export type CdcConfig = z.infer; - -/** - * Database Table Configuration - */ -export const DatabaseTableSchema = lazySchema(() => z.object({ - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Table name in ObjectStack (snake_case)'), - label: z.string().describe('Display label'), - schema: z.string().optional().describe('Database schema name'), - tableName: z.string().describe('Actual table name in database'), - primaryKey: z.string().describe('Primary key column'), - enabled: z.boolean().default(true).describe('Enable sync for this table'), - fieldMappings: z.array(FieldMappingSchema).optional().describe('Table-specific field mappings'), - whereClause: z.string().optional().describe('SQL WHERE clause for filtering'), -})); - -export type DatabaseTable = z.infer; - -/** - * Database Connector Configuration Schema - */ -export const DatabaseConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('database'), - - /** - * Database provider - */ - provider: DatabaseProviderSchema.describe('Database provider type'), - - /** - * Connection configuration - */ - connectionConfig: z.object({ - host: z.string().describe('Database host'), - port: z.number().min(1).max(65535).describe('Database port'), - database: z.string().describe('Database name'), - username: z.string().describe('Database username'), - password: z.string().describe('Database password (typically from ENV)'), - options: z.record(z.string(), z.unknown()).optional().describe('Driver-specific connection options'), - }).describe('Database connection configuration'), - - /** - * Connection pool configuration - */ - poolConfig: DatabasePoolConfigSchema.optional().describe('Connection pool configuration'), - - /** - * SSL/TLS configuration - */ - sslConfig: SslConfigSchema.optional().describe('SSL/TLS configuration'), - - /** - * Tables to sync - */ - tables: z.array(DatabaseTableSchema).describe('Tables to sync'), - - /** - * Change Data Capture configuration - */ - cdcConfig: CdcConfigSchema.optional().describe('CDC configuration'), - - /** - * Read replica configuration - */ - readReplicaConfig: z.object({ - enabled: z.boolean().default(false).describe('Use read replicas'), - hosts: z.array(z.object({ - host: z.string().describe('Replica host'), - port: z.number().min(1).max(65535).describe('Replica port'), - weight: z.number().min(0).max(1).default(1).describe('Load balancing weight'), - })).describe('Read replica hosts'), - }).optional().describe('Read replica configuration'), - - /** - * Query timeout - */ - queryTimeoutMs: z.number().min(1000).max(300000).optional().default(30000).describe('Query timeout in ms'), - - /** - * Enable query logging - */ - enableQueryLogging: z.boolean().optional().default(false).describe('Enable SQL query logging'), -})); - -export type DatabaseConnector = z.infer; - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: PostgreSQL Connector Configuration - */ -export const postgresConnectorExample = { - name: 'postgres_production', - label: 'Production PostgreSQL', - type: 'database', - provider: 'postgresql', - authentication: { - type: 'basic', - username: '${DB_USERNAME}', - password: '${DB_PASSWORD}', - }, - connectionConfig: { - host: 'db.example.com', - port: 5432, - database: 'production', - username: '${DB_USERNAME}', - password: '${DB_PASSWORD}', - }, - poolConfig: { - min: 2, - max: 20, - idleTimeoutMs: 30000, - connectionTimeoutMs: 10000, - acquireTimeoutMs: 30000, - evictionRunIntervalMs: 30000, - testOnBorrow: true, - }, - sslConfig: { - enabled: true, - rejectUnauthorized: true, - }, - tables: [ - { - name: 'customer', - label: 'Customer', - schema: 'public', - tableName: 'customers', - primaryKey: 'id', - enabled: true, - }, - { - name: 'order', - label: 'Order', - schema: 'public', - tableName: 'orders', - primaryKey: 'id', - enabled: true, - whereClause: 'status != \'archived\'', - }, - ], - cdcConfig: { - enabled: true, - method: 'log_based', - slotName: 'objectstack_replication_slot', - publicationName: 'objectstack_publication', - batchSize: 1000, - pollIntervalMs: 1000, - }, - syncConfig: { - strategy: 'incremental', - direction: 'bidirectional', - realtimeSync: true, - conflictResolution: 'latest_wins', - batchSize: 1000, - deleteMode: 'soft_delete', - }, - status: 'active', - enabled: true, -}; - -/** - * Example: MongoDB Connector Configuration - */ -export const mongoConnectorExample = { - name: 'mongodb_analytics', - label: 'MongoDB Analytics', - type: 'database', - provider: 'mongodb', - authentication: { - type: 'basic', - username: '${MONGO_USERNAME}', - password: '${MONGO_PASSWORD}', - }, - connectionConfig: { - host: 'mongodb.example.com', - port: 27017, - database: 'analytics', - username: '${MONGO_USERNAME}', - password: '${MONGO_PASSWORD}', - options: { - authSource: 'admin', - replicaSet: 'rs0', - }, - }, - tables: [ - { - name: 'event', - label: 'Event', - tableName: 'events', - primaryKey: 'id', - enabled: true, - }, - ], - cdcConfig: { - enabled: true, - method: 'log_based', - batchSize: 1000, - pollIntervalMs: 500, - }, - syncConfig: { - strategy: 'incremental', - direction: 'import', - batchSize: 1000, - }, - status: 'active', - enabled: true, -}; - -/** - * Example: Snowflake Connector Configuration - */ -export const snowflakeConnectorExample = { - name: 'snowflake_warehouse', - label: 'Snowflake Data Warehouse', - type: 'database', - provider: 'snowflake', - authentication: { - type: 'basic', - username: '${SNOWFLAKE_USERNAME}', - password: '${SNOWFLAKE_PASSWORD}', - }, - connectionConfig: { - host: 'account.snowflakecomputing.com', - port: 443, - database: 'ANALYTICS_DB', - username: '${SNOWFLAKE_USERNAME}', - password: '${SNOWFLAKE_PASSWORD}', - options: { - warehouse: 'COMPUTE_WH', - schema: 'PUBLIC', - role: 'ANALYST', - }, - }, - tables: [ - { - name: 'sales_summary', - label: 'Sales Summary', - schema: 'PUBLIC', - tableName: 'SALES_SUMMARY', - primaryKey: 'ID', - enabled: true, - }, - ], - syncConfig: { - strategy: 'full', - direction: 'import', - schedule: '0 2 * * *', // Daily at 2 AM - batchSize: 5000, - }, - queryTimeoutMs: 60000, - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/connector/file-storage.test.ts b/packages/spec/src/integration/connector/file-storage.test.ts deleted file mode 100644 index 6f99f3ec3d..0000000000 --- a/packages/spec/src/integration/connector/file-storage.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - FileStorageProviderSchema, - FileAccessPatternSchema, - FileMetadataConfigSchema, - MultipartUploadConfigSchema, - FileVersioningConfigSchema, - FileFilterConfigSchema, - StorageBucketSchema, - FileStorageConnectorSchema, -} from './file-storage.zod'; - -// Shared base connector fields for FileStorageConnector -const baseConnector = { - name: 's3_assets', - label: 'S3 Assets', - type: 'file_storage' as const, - authentication: { type: 'none' as const }, - provider: 's3' as const, - buckets: [ - { name: 'my_bucket', label: 'My Bucket', bucketName: 'actual-bucket' }, - ], -}; - -describe('FileStorageProviderSchema', () => { - it('should accept valid providers', () => { - for (const v of ['s3', 'azure_blob', 'gcs', 'dropbox', 'box', 'onedrive', 'google_drive', 'sharepoint', 'ftp', 'local', 'custom']) { - expect(FileStorageProviderSchema.parse(v)).toBe(v); - } - }); - - it('should reject invalid provider', () => { - expect(() => FileStorageProviderSchema.parse('invalid')).toThrow(); - }); -}); - -describe('FileAccessPatternSchema', () => { - it('should accept valid access patterns', () => { - for (const v of ['public_read', 'private', 'authenticated_read', 'bucket_owner_read', 'bucket_owner_full']) { - expect(FileAccessPatternSchema.parse(v)).toBe(v); - } - }); - - it('should reject invalid access pattern', () => { - expect(() => FileAccessPatternSchema.parse('unknown')).toThrow(); - }); -}); - -describe('FileMetadataConfigSchema', () => { - it('should accept valid config with defaults', () => { - const result = FileMetadataConfigSchema.parse({}); - expect(result.extractMetadata).toBe(true); - }); - - it('should accept full config', () => { - const data = { - extractMetadata: false, - metadataFields: ['content_type', 'file_size', 'etag'], - customMetadata: { env: 'prod' }, - }; - expect(() => FileMetadataConfigSchema.parse(data)).not.toThrow(); - }); - - it('should reject invalid metadataFields value', () => { - expect(() => FileMetadataConfigSchema.parse({ metadataFields: ['bad_field'] })).toThrow(); - }); -}); - -describe('MultipartUploadConfigSchema', () => { - it('should apply defaults', () => { - const result = MultipartUploadConfigSchema.parse({}); - expect(result.enabled).toBe(true); - expect(result.partSize).toBe(5 * 1024 * 1024); - expect(result.maxConcurrentParts).toBe(5); - expect(result.threshold).toBe(100 * 1024 * 1024); - }); - - it('should reject partSize below minimum', () => { - expect(() => MultipartUploadConfigSchema.parse({ partSize: 100 })).toThrow(); - }); - - it('should reject maxConcurrentParts out of range', () => { - expect(() => MultipartUploadConfigSchema.parse({ maxConcurrentParts: 0 })).toThrow(); - expect(() => MultipartUploadConfigSchema.parse({ maxConcurrentParts: 11 })).toThrow(); - }); -}); - -describe('FileVersioningConfigSchema', () => { - it('should apply defaults', () => { - const result = FileVersioningConfigSchema.parse({}); - expect(result.enabled).toBe(false); - }); - - it('should accept valid config', () => { - const result = FileVersioningConfigSchema.parse({ enabled: true, maxVersions: 10, retentionDays: 30 }); - expect(result.maxVersions).toBe(10); - }); - - it('should reject maxVersions out of range', () => { - expect(() => FileVersioningConfigSchema.parse({ maxVersions: 0 })).toThrow(); - expect(() => FileVersioningConfigSchema.parse({ maxVersions: 101 })).toThrow(); - }); -}); - -describe('FileFilterConfigSchema', () => { - it('should accept empty config', () => { - expect(() => FileFilterConfigSchema.parse({})).not.toThrow(); - }); - - it('should accept full config', () => { - const data = { - includePatterns: ['*.jpg'], - excludePatterns: ['*.tmp'], - minFileSize: 0, - maxFileSize: 1024, - allowedExtensions: ['.jpg'], - blockedExtensions: ['.exe'], - }; - expect(() => FileFilterConfigSchema.parse(data)).not.toThrow(); - }); - - it('should reject negative minFileSize', () => { - expect(() => FileFilterConfigSchema.parse({ minFileSize: -1 })).toThrow(); - }); - - it('should reject maxFileSize less than 1', () => { - expect(() => FileFilterConfigSchema.parse({ maxFileSize: 0 })).toThrow(); - }); -}); - -describe('StorageBucketSchema', () => { - it('should accept valid bucket', () => { - const data = { name: 'my_bucket', label: 'My Bucket', bucketName: 'actual-bucket-name' }; - const result = StorageBucketSchema.parse(data); - expect(result.enabled).toBe(true); - }); - - it('should accept bucket with all optional fields', () => { - const data = { - name: 'docs_bucket', - label: 'Documents', - bucketName: 'docs-bucket', - region: 'us-east-1', - enabled: false, - prefix: 'docs/', - accessPattern: 'private', - fileFilters: { allowedExtensions: ['.pdf'] }, - }; - expect(() => StorageBucketSchema.parse(data)).not.toThrow(); - }); - - it('should reject non-snake_case name', () => { - expect(() => StorageBucketSchema.parse({ name: 'MyBucket', label: 'X', bucketName: 'b' })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => StorageBucketSchema.parse({ name: 'b' })).toThrow(); - }); -}); - -describe('FileStorageConnectorSchema', () => { - it('should accept minimal valid connector', () => { - expect(() => FileStorageConnectorSchema.parse(baseConnector)).not.toThrow(); - }); - - it('should apply defaults', () => { - const result = FileStorageConnectorSchema.parse(baseConnector); - expect(result.transferAcceleration).toBe(false); - expect(result.bufferSize).toBe(64 * 1024); - expect(result.enabled).toBe(true); - }); - - it('should accept full connector config', () => { - const full = { - ...baseConnector, - storageConfig: { endpoint: 'https://s3.example.com', region: 'us-east-1', pathStyle: true }, - metadataConfig: { extractMetadata: true, metadataFields: ['content_type'] }, - multipartConfig: { enabled: true }, - versioningConfig: { enabled: true, maxVersions: 5 }, - encryption: { enabled: true, algorithm: 'AES256' }, - lifecyclePolicy: { enabled: true, deleteAfterDays: 90 }, - contentProcessing: { extractText: true, generateThumbnails: true, thumbnailSizes: [{ width: 100, height: 100 }], virusScan: false }, - bufferSize: 2048, - transferAcceleration: true, - }; - expect(() => FileStorageConnectorSchema.parse(full)).not.toThrow(); - }); - - it('should reject wrong type literal', () => { - expect(() => FileStorageConnectorSchema.parse({ ...baseConnector, type: 'database' })).toThrow(); - }); - - it('should reject invalid provider', () => { - expect(() => FileStorageConnectorSchema.parse({ ...baseConnector, provider: 'invalid' })).toThrow(); - }); - - it('should reject missing buckets', () => { - const { buckets: _, ...noBuckets } = baseConnector; - expect(() => FileStorageConnectorSchema.parse(noBuckets)).toThrow(); - }); - - it('should reject bufferSize below minimum', () => { - expect(() => FileStorageConnectorSchema.parse({ ...baseConnector, bufferSize: 100 })).toThrow(); - }); - - it('should reject invalid storageConfig endpoint', () => { - expect(() => FileStorageConnectorSchema.parse({ ...baseConnector, storageConfig: { endpoint: 'not-a-url' } })).toThrow(); - }); -}); diff --git a/packages/spec/src/integration/connector/file-storage.zod.ts b/packages/spec/src/integration/connector/file-storage.zod.ts deleted file mode 100644 index 151f26c92d..0000000000 --- a/packages/spec/src/integration/connector/file-storage.zod.ts +++ /dev/null @@ -1,400 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { - ConnectorSchema, -} from '../connector.zod'; - -/** - * File Storage Connector Protocol Template - * - * Specialized connector for file storage systems (S3, Azure Blob, Google Cloud Storage, etc.) - * Extends the base connector with file-specific features like multipart uploads, - * versioning, and metadata extraction. - */ - -/** - * File Storage Provider Types - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const FileStorageProviderSchema = lazySchema(() => z.enum([ - 's3', // Amazon S3 - 'azure_blob', // Azure Blob Storage - 'gcs', // Google Cloud Storage - 'dropbox', // Dropbox - 'box', // Box - 'onedrive', // Microsoft OneDrive - 'google_drive', // Google Drive - 'sharepoint', // SharePoint - 'ftp', // FTP/SFTP - 'local', // Local file system - 'custom', // Custom file storage -]).describe('File storage provider type')); - -export type FileStorageProvider = z.infer; - -/** - * File Access Pattern - */ -export const FileAccessPatternSchema = lazySchema(() => z.enum([ - 'public_read', // Public read access - 'private', // Private access - 'authenticated_read', // Requires authentication - 'bucket_owner_read', // Bucket owner has read access - 'bucket_owner_full', // Bucket owner has full control -]).describe('File access pattern')); - -export type FileAccessPattern = z.infer; - -/** - * File Metadata Configuration - */ -export const FileMetadataConfigSchema = lazySchema(() => z.object({ - extractMetadata: z.boolean().default(true).describe('Extract file metadata'), - - metadataFields: z.array(z.enum([ - 'content_type', - 'file_size', - 'last_modified', - 'etag', - 'checksum', - 'creator', - 'created_at', - 'custom', - ])).optional().describe('Metadata fields to extract'), - - customMetadata: z.record(z.string(), z.string()).optional().describe('Custom metadata key-value pairs'), -})); - -export type FileMetadataConfig = z.infer; - -/** - * Multipart Upload Configuration - */ -export const MultipartUploadConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(true).describe('Enable multipart uploads'), - - partSize: z.number().min(5 * 1024 * 1024).default(5 * 1024 * 1024).describe('Part size in bytes (min 5MB)'), - - maxConcurrentParts: z.number().min(1).max(10).default(5).describe('Maximum concurrent part uploads'), - - threshold: z.number().min(5 * 1024 * 1024).default(100 * 1024 * 1024).describe('File size threshold for multipart upload in bytes'), -})); - -export type MultipartUploadConfig = z.infer; - -/** - * File Versioning Configuration - */ -export const FileVersioningConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable file versioning'), - - maxVersions: z.number().min(1).max(100).optional().describe('Maximum versions to retain'), - - retentionDays: z.number().min(1).optional().describe('Version retention period in days'), -})); - -export type FileVersioningConfig = z.infer; - -/** - * File Filter Configuration - */ -export const FileFilterConfigSchema = lazySchema(() => z.object({ - includePatterns: z.array(z.string()).optional().describe('File patterns to include (glob)'), - - excludePatterns: z.array(z.string()).optional().describe('File patterns to exclude (glob)'), - - minFileSize: z.number().min(0).optional().describe('Minimum file size in bytes'), - - maxFileSize: z.number().min(1).optional().describe('Maximum file size in bytes'), - - allowedExtensions: z.array(z.string()).optional().describe('Allowed file extensions'), - - blockedExtensions: z.array(z.string()).optional().describe('Blocked file extensions'), -})); - -export type FileFilterConfig = z.infer; - -/** - * File Storage Bucket/Container Configuration - */ -export const StorageBucketSchema = lazySchema(() => z.object({ - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Bucket identifier in ObjectStack (snake_case)'), - label: z.string().describe('Display label'), - bucketName: z.string().describe('Actual bucket/container name in storage system'), - region: z.string().optional().describe('Storage region'), - enabled: z.boolean().default(true).describe('Enable sync for this bucket'), - prefix: z.string().optional().describe('Prefix/path within bucket'), - accessPattern: FileAccessPatternSchema.optional().describe('Access pattern'), - fileFilters: FileFilterConfigSchema.optional().describe('File filter configuration'), -})); - -export type StorageBucket = z.infer; - -/** - * File Storage Connector Configuration Schema - */ -export const FileStorageConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('file_storage'), - - /** - * File storage provider - */ - provider: FileStorageProviderSchema.describe('File storage provider type'), - - /** - * Storage configuration - */ - storageConfig: z.object({ - endpoint: z.string().url().optional().describe('Custom endpoint URL'), - region: z.string().optional().describe('Default region'), - pathStyle: z.boolean().optional().default(false).describe('Use path-style URLs (for S3-compatible)'), - }).optional().describe('Storage configuration'), - - /** - * Buckets/containers to sync - */ - buckets: z.array(StorageBucketSchema).describe('Buckets/containers to sync'), - - /** - * File metadata configuration - */ - metadataConfig: FileMetadataConfigSchema.optional().describe('Metadata extraction configuration'), - - /** - * Multipart upload configuration - */ - multipartConfig: MultipartUploadConfigSchema.optional().describe('Multipart upload configuration'), - - /** - * File versioning configuration - */ - versioningConfig: FileVersioningConfigSchema.optional().describe('File versioning configuration'), - - /** - * Enable server-side encryption - */ - encryption: z.object({ - enabled: z.boolean().default(false).describe('Enable server-side encryption'), - algorithm: z.enum(['AES256', 'aws:kms', 'custom']).optional().describe('Encryption algorithm'), - kmsKeyId: z.string().optional().describe('KMS key ID (for aws:kms)'), - }).optional().describe('Encryption configuration'), - - /** - * Lifecycle policy - */ - lifecyclePolicy: z.object({ - enabled: z.boolean().default(false).describe('Enable lifecycle policy'), - deleteAfterDays: z.number().min(1).optional().describe('Delete files after N days'), - archiveAfterDays: z.number().min(1).optional().describe('Archive files after N days'), - }).optional().describe('Lifecycle policy'), - - /** - * Content processing configuration - */ - contentProcessing: z.object({ - extractText: z.boolean().default(false).describe('Extract text from documents'), - generateThumbnails: z.boolean().default(false).describe('Generate image thumbnails'), - thumbnailSizes: z.array(z.object({ - width: z.number().min(1), - height: z.number().min(1), - })).optional().describe('Thumbnail sizes'), - virusScan: z.boolean().default(false).describe('Scan for viruses'), - }).optional().describe('Content processing configuration'), - - /** - * Download/upload buffer size - */ - bufferSize: z.number().min(1024).default(64 * 1024).describe('Buffer size in bytes'), - - /** - * Enable transfer acceleration (for supported providers) - */ - transferAcceleration: z.boolean().default(false).describe('Enable transfer acceleration'), -})); - -export type FileStorageConnector = z.infer; - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: Amazon S3 Connector Configuration - */ -export const s3ConnectorExample = { - name: 's3_production_assets', - label: 'Production S3 Assets', - type: 'file_storage', - provider: 's3', - authentication: { - type: 'api_key', - apiKey: '${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}', - headerName: 'Authorization', - }, - storageConfig: { - region: 'us-east-1', - pathStyle: false, - }, - buckets: [ - { - name: 'product_images', - label: 'Product Images', - bucketName: 'my-company-product-images', - region: 'us-east-1', - enabled: true, - prefix: 'products/', - accessPattern: 'public_read', - fileFilters: { - allowedExtensions: ['.jpg', '.jpeg', '.png', '.webp'], - maxFileSize: 10 * 1024 * 1024, // 10MB - }, - }, - { - name: 'customer_documents', - label: 'Customer Documents', - bucketName: 'my-company-customer-docs', - region: 'us-east-1', - enabled: true, - accessPattern: 'private', - fileFilters: { - allowedExtensions: ['.pdf', '.docx', '.xlsx'], - maxFileSize: 50 * 1024 * 1024, // 50MB - }, - }, - ], - metadataConfig: { - extractMetadata: true, - metadataFields: ['content_type', 'file_size', 'last_modified', 'etag'], - }, - multipartConfig: { - enabled: true, - partSize: 5 * 1024 * 1024, // 5MB - maxConcurrentParts: 5, - threshold: 100 * 1024 * 1024, // 100MB - }, - versioningConfig: { - enabled: true, - maxVersions: 10, - }, - encryption: { - enabled: true, - algorithm: 'aws:kms', - kmsKeyId: '${AWS_KMS_KEY_ID}', - }, - contentProcessing: { - extractText: true, - generateThumbnails: true, - thumbnailSizes: [ - { width: 150, height: 150 }, - { width: 300, height: 300 }, - { width: 600, height: 600 }, - ], - virusScan: true, - }, - syncConfig: { - strategy: 'incremental', - direction: 'bidirectional', - realtimeSync: true, - conflictResolution: 'latest_wins', - batchSize: 100, - }, - transferAcceleration: true, - status: 'active', - enabled: true, -}; - -/** - * Example: Google Drive Connector Configuration - */ -export const googleDriveConnectorExample = { - name: 'google_drive_team', - label: 'Google Drive Team Folder', - type: 'file_storage', - provider: 'google_drive', - authentication: { - type: 'oauth2', - clientId: '${GOOGLE_CLIENT_ID}', - clientSecret: '${GOOGLE_CLIENT_SECRET}', - authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - grantType: 'authorization_code', - scopes: ['https://www.googleapis.com/auth/drive.file'], - }, - buckets: [ - { - name: 'team_drive', - label: 'Team Drive', - bucketName: 'shared-team-drive', - enabled: true, - fileFilters: { - excludePatterns: ['*.tmp', '~$*'], - }, - }, - ], - metadataConfig: { - extractMetadata: true, - metadataFields: ['content_type', 'file_size', 'last_modified', 'creator', 'created_at'], - }, - versioningConfig: { - enabled: true, - maxVersions: 5, - }, - syncConfig: { - strategy: 'incremental', - direction: 'bidirectional', - realtimeSync: true, - conflictResolution: 'latest_wins', - batchSize: 50, - }, - status: 'active', - enabled: true, -}; - -/** - * Example: Azure Blob Storage Connector Configuration - */ -export const azureBlobConnectorExample = { - name: 'azure_blob_storage', - label: 'Azure Blob Storage', - type: 'file_storage', - provider: 'azure_blob', - authentication: { - type: 'api_key', - apiKey: '${AZURE_STORAGE_ACCOUNT_KEY}', - headerName: 'x-ms-blob-type', - }, - storageConfig: { - endpoint: 'https://myaccount.blob.core.windows.net', - }, - buckets: [ - { - name: 'archive_container', - label: 'Archive Container', - bucketName: 'archive', - enabled: true, - accessPattern: 'private', - }, - ], - metadataConfig: { - extractMetadata: true, - metadataFields: ['content_type', 'file_size', 'last_modified', 'etag'], - }, - encryption: { - enabled: true, - algorithm: 'AES256', - }, - lifecyclePolicy: { - enabled: true, - archiveAfterDays: 90, - deleteAfterDays: 365, - }, - syncConfig: { - strategy: 'incremental', - direction: 'import', - schedule: '0 1 * * *', // Daily at 1 AM - batchSize: 200, - }, - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/connector/github.test.ts b/packages/spec/src/integration/connector/github.test.ts deleted file mode 100644 index 778878e585..0000000000 --- a/packages/spec/src/integration/connector/github.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - GitHubConnectorSchema, - GitHubRepositorySchema, - GitHubCommitConfigSchema, - GitHubPullRequestConfigSchema, - GitHubActionsWorkflowSchema, - GitHubReleaseConfigSchema, - GitHubIssueTrackingSchema, - githubPublicConnectorExample, - githubEnterpriseConnectorExample, - type GitHubConnector, -} from './github.zod'; - -describe('GitHubRepositorySchema', () => { - it('should accept minimal repository config', () => { - const repo = { - owner: 'objectstack-ai', - name: 'spec', - }; - - const result = GitHubRepositorySchema.parse(repo); - expect(result.defaultBranch).toBe('main'); - expect(result.autoMerge).toBe(false); - }); - - it('should accept repository with branch protection', () => { - const repo = { - owner: 'objectstack-ai', - name: 'spec', - defaultBranch: 'main', - branchProtection: { - requiredReviewers: 2, - requireStatusChecks: true, - enforceAdmins: true, - }, - }; - - expect(() => GitHubRepositorySchema.parse(repo)).not.toThrow(); - }); - - it('should accept repository with topics', () => { - const repo = { - owner: 'objectstack-ai', - name: 'spec', - topics: ['objectstack', 'low-code', 'metadata'], - }; - - expect(() => GitHubRepositorySchema.parse(repo)).not.toThrow(); - }); -}); - -describe('GitHubCommitConfigSchema', () => { - it('should accept minimal commit config', () => { - const config = {}; - - const result = GitHubCommitConfigSchema.parse(config); - expect(result.signCommits).toBe(false); - expect(result.useConventionalCommits).toBe(true); - }); - - it('should accept full commit config', () => { - const config = { - authorName: 'ObjectStack Bot', - authorEmail: 'bot@objectstack.ai', - signCommits: true, - messageTemplate: '{{type}}: {{message}}', - useConventionalCommits: true, - }; - - expect(() => GitHubCommitConfigSchema.parse(config)).not.toThrow(); - }); - - it('should validate email format', () => { - expect(() => GitHubCommitConfigSchema.parse({ - authorEmail: 'invalid-email', - })).toThrow(); - - expect(() => GitHubCommitConfigSchema.parse({ - authorEmail: 'valid@email.com', - })).not.toThrow(); - }); -}); - -describe('GitHubPullRequestConfigSchema', () => { - it('should accept minimal PR config', () => { - const config = {}; - - const result = GitHubPullRequestConfigSchema.parse(config); - expect(result.draftByDefault).toBe(false); - expect(result.deleteHeadBranch).toBe(true); - }); - - it('should accept PR config with templates and reviewers', () => { - const config = { - titleTemplate: '{{type}}: {{description}}', - bodyTemplate: '## Changes\n\n{{changes}}', - defaultReviewers: ['reviewer1', 'reviewer2'], - defaultAssignees: ['assignee1'], - defaultLabels: ['automated', 'needs-review'], - }; - - expect(() => GitHubPullRequestConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('GitHubActionsWorkflowSchema', () => { - it('should accept minimal workflow', () => { - const workflow = { - name: 'CI', - path: '.github/workflows/ci.yml', - }; - - const result = GitHubActionsWorkflowSchema.parse(workflow); - expect(result.enabled).toBe(true); - }); - - it('should accept all trigger types', () => { - const triggers = ['push', 'pull_request', 'release', 'schedule', 'workflow_dispatch', 'repository_dispatch'] as const; - - triggers.forEach(trigger => { - const workflow = { - name: 'Test', - path: '.github/workflows/test.yml', - triggers: [trigger], - }; - expect(() => GitHubActionsWorkflowSchema.parse(workflow)).not.toThrow(); - }); - }); - - it('should accept workflow with env and secrets', () => { - const workflow = { - name: 'Deploy', - path: '.github/workflows/deploy.yml', - env: { - NODE_ENV: 'production', - API_URL: 'https://api.example.com', - }, - secrets: ['DEPLOY_TOKEN', 'AWS_SECRET_KEY'], - }; - - expect(() => GitHubActionsWorkflowSchema.parse(workflow)).not.toThrow(); - }); -}); - -describe('GitHubReleaseConfigSchema', () => { - it('should accept minimal release config', () => { - const config = {}; - - const result = GitHubReleaseConfigSchema.parse(config); - expect(result.tagPattern).toBe('v*'); - expect(result.semanticVersioning).toBe(true); - expect(result.autoReleaseNotes).toBe(true); - }); - - it('should accept full release config', () => { - const config = { - tagPattern: 'release/*', - semanticVersioning: true, - autoReleaseNotes: true, - releaseNameTemplate: 'Release {{version}}', - preReleasePattern: '*-rc*', - draftByDefault: true, - }; - - expect(() => GitHubReleaseConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('GitHubIssueTrackingSchema', () => { - it('should accept minimal issue tracking config', () => { - const config = {}; - - const result = GitHubIssueTrackingSchema.parse(config); - expect(result.enabled).toBe(true); - expect(result.autoAssign).toBe(false); - }); - - it('should accept auto-close stale issues config', () => { - const config = { - autoCloseStale: { - enabled: true, - daysBeforeStale: 30, - daysBeforeClose: 7, - staleLabel: 'wontfix', - }, - }; - - const result = GitHubIssueTrackingSchema.parse(config); - expect(result.autoCloseStale?.enabled).toBe(true); - expect(result.autoCloseStale?.daysBeforeStale).toBe(30); - }); -}); - -describe('GitHubConnectorSchema', () => { - describe('Basic Properties', () => { - it('should accept minimal GitHub connector', () => { - const connector: GitHubConnector = { - name: 'github_test', - label: 'GitHub Test', - type: 'saas', - provider: 'github', - authentication: { - type: 'oauth2', - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - grantType: 'authorization_code', - }, - repositories: [ - { - owner: 'test-org', - name: 'test-repo', - }, - ], - }; - - const result = GitHubConnectorSchema.parse(connector); - expect(result.baseUrl).toBe('https://api.github.com'); - expect(result.enableWebhooks).toBe(true); - }); - - it('should enforce snake_case for connector name', () => { - const validNames = ['github_test', 'github_production', '_internal']; - validNames.forEach(name => { - expect(() => GitHubConnectorSchema.parse({ - name, - label: 'Test', - type: 'saas', - provider: 'github', - authentication: { type: 'oauth2', clientId: 'x', clientSecret: 'y', authorizationUrl: 'https://x.com', tokenUrl: 'https://y.com', grantType: 'authorization_code' }, - repositories: [{ owner: 'x', name: 'y' }], - })).not.toThrow(); - }); - - const invalidNames = ['githubTest', 'GitHub-Test', '123github']; - invalidNames.forEach(name => { - expect(() => GitHubConnectorSchema.parse({ - name, - label: 'Test', - type: 'saas', - provider: 'github', - authentication: { type: 'oauth2', clientId: 'x', clientSecret: 'y', authorizationUrl: 'https://x.com', tokenUrl: 'https://y.com', grantType: 'authorization_code' }, - repositories: [{ owner: 'x', name: 'y' }], - })).toThrow(); - }); - }); - - it('should accept GitHub Enterprise provider', () => { - const connector: GitHubConnector = { - name: 'github_enterprise', - label: 'GitHub Enterprise', - type: 'saas', - provider: 'github_enterprise', - baseUrl: 'https://github.enterprise.com/api/v3', - authentication: { - type: 'oauth2', - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - authorizationUrl: 'https://github.enterprise.com/login/oauth/authorize', - tokenUrl: 'https://github.enterprise.com/login/oauth/access_token', - grantType: 'authorization_code', - }, - repositories: [ - { - owner: 'enterprise-org', - name: 'app', - }, - ], - }; - - expect(() => GitHubConnectorSchema.parse(connector)).not.toThrow(); - }); - }); - - describe('Complete Configuration', () => { - it('should accept full GitHub connector with all features', () => { - const connector: GitHubConnector = { - name: 'github_full', - label: 'GitHub Full Config', - type: 'saas', - provider: 'github', - baseUrl: 'https://api.github.com', - - authentication: { - type: 'oauth2', - clientId: '${GITHUB_CLIENT_ID}', - clientSecret: '${GITHUB_CLIENT_SECRET}', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - grantType: 'authorization_code', - scopes: ['repo', 'workflow'], - }, - - repositories: [ - { - owner: 'objectstack-ai', - name: 'spec', - defaultBranch: 'main', - autoMerge: false, - branchProtection: { - requiredReviewers: 1, - requireStatusChecks: true, - }, - topics: ['objectstack', 'metadata'], - }, - ], - - commitConfig: { - authorName: 'Bot', - authorEmail: 'bot@example.com', - useConventionalCommits: true, - }, - - pullRequestConfig: { - defaultReviewers: ['reviewer'], - defaultLabels: ['automated'], - }, - - workflows: [ - { - name: 'CI', - path: '.github/workflows/ci.yml', - triggers: ['push', 'pull_request'], - }, - ], - - releaseConfig: { - semanticVersioning: true, - autoReleaseNotes: true, - }, - - issueTracking: { - enabled: true, - autoCloseStale: { - enabled: true, - daysBeforeStale: 60, - daysBeforeClose: 7, - staleLabel: 'stale', - }, - }, - - enableWebhooks: true, - webhookEvents: ['push', 'pull_request', 'release'], - - status: 'active', - enabled: true, - }; - - expect(() => GitHubConnectorSchema.parse(connector)).not.toThrow(); - }); - }); - - describe('Example Configurations', () => { - it('should accept GitHub.com public connector example', () => { - expect(() => GitHubConnectorSchema.parse(githubPublicConnectorExample)).not.toThrow(); - }); - - it('should accept GitHub Enterprise connector example', () => { - expect(() => GitHubConnectorSchema.parse(githubEnterpriseConnectorExample)).not.toThrow(); - }); - }); -}); diff --git a/packages/spec/src/integration/connector/github.zod.ts b/packages/spec/src/integration/connector/github.zod.ts deleted file mode 100644 index bc740fd8e9..0000000000 --- a/packages/spec/src/integration/connector/github.zod.ts +++ /dev/null @@ -1,530 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { TemplateExpressionInputSchema } from '../../shared/expression.zod'; -import { - ConnectorSchema, -} from '../connector.zod'; - -/** - * GitHub Connector Protocol - * - * Specialized connector for GitHub integration enabling automated - * version control operations, CI/CD workflows, and release management. - * - * Use Cases: - * - Automated code commits and pull requests - * - GitHub Actions workflow management - * - Issue and project tracking - * - Release and tag management - * - Repository administration - * - * @example - * ```typescript - * import { GitHubConnector } from '@objectstack/spec/integration'; - * - * const githubConnector: GitHubConnector = { - * name: 'github_enterprise', - * label: 'GitHub Enterprise', - * type: 'saas', - * provider: 'github', - * baseUrl: 'https://api.github.com', - * authentication: { - * type: 'oauth2', - * clientId: '${GITHUB_CLIENT_ID}', - * clientSecret: '${GITHUB_CLIENT_SECRET}', - * authorizationUrl: 'https://github.com/login/oauth/authorize', - * tokenUrl: 'https://github.com/login/oauth/access_token', - * grantType: 'authorization_code', - * scopes: ['repo', 'workflow', 'admin:org'], - * }, - * repositories: [ - * { - * owner: 'objectstack-ai', - * name: 'spec', - * defaultBranch: 'main', - * autoMerge: false, - * }, - * ], - * }; - * ``` - */ - -/** - * GitHub Provider Type - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const GitHubProviderSchema = lazySchema(() => z.enum([ - 'github', // GitHub.com - 'github_enterprise', // GitHub Enterprise Server -]).describe('GitHub provider type')); - -export type GitHubProvider = z.infer; - -/** - * GitHub Repository Configuration - * Defines a repository to integrate with - */ -export const GitHubRepositorySchema = lazySchema(() => z.object({ - /** - * Repository owner (organization or user) - */ - owner: z.string().describe('Repository owner (organization or username)'), - - /** - * Repository name - */ - name: z.string().describe('Repository name'), - - /** - * Default branch name - */ - defaultBranch: z.string().optional().default('main').describe('Default branch name'), - - /** - * Enable auto-merge for PRs - */ - autoMerge: z.boolean().optional().default(false).describe('Enable auto-merge for pull requests'), - - /** - * Branch protection rules - */ - branchProtection: z.object({ - requiredReviewers: z.number().int().min(0).optional().default(1).describe('Required number of reviewers'), - requireStatusChecks: z.boolean().optional().default(true).describe('Require status checks to pass'), - enforceAdmins: z.boolean().optional().default(false).describe('Enforce protections for admins'), - allowForcePushes: z.boolean().optional().default(false).describe('Allow force pushes'), - allowDeletions: z.boolean().optional().default(false).describe('Allow branch deletions'), - }).optional().describe('Branch protection configuration'), - - /** - * Repository topics/tags - */ - topics: z.array(z.string()).optional().describe('Repository topics'), -})); - -export type GitHubRepository = z.infer; - -/** - * GitHub Commit Configuration - */ -export const GitHubCommitConfigSchema = lazySchema(() => z.object({ - /** - * Commit author name - */ - authorName: z.string().optional().describe('Commit author name'), - - /** - * Commit author email - */ - authorEmail: z.string().email().optional().describe('Commit author email'), - - /** - * GPG sign commits - */ - signCommits: z.boolean().optional().default(false).describe('Sign commits with GPG'), - - /** - * Commit message template - */ - messageTemplate: z.string().optional().describe('Commit message template'), - - /** - * Conventional commits format - */ - useConventionalCommits: z.boolean().optional().default(true).describe('Use conventional commits format'), -})); - -export type GitHubCommitConfig = z.infer; - -/** - * GitHub Pull Request Configuration - */ -export const GitHubPullRequestConfigSchema = lazySchema(() => z.object({ - /** - * Default PR title template - */ - titleTemplate: TemplateExpressionInputSchema.optional().describe('PR title template — supports {{var}} interpolation'), - - /** - * Default PR body template - */ - bodyTemplate: TemplateExpressionInputSchema.optional().describe('PR body template — supports {{var}} interpolation'), - - /** - * Default reviewers - */ - defaultReviewers: z.array(z.string()).optional().describe('Default reviewers (usernames)'), - - /** - * Default assignees - */ - defaultAssignees: z.array(z.string()).optional().describe('Default assignees (usernames)'), - - /** - * Default labels - */ - defaultLabels: z.array(z.string()).optional().describe('Default labels'), - - /** - * Enable draft PRs by default - */ - draftByDefault: z.boolean().optional().default(false).describe('Create draft PRs by default'), - - /** - * Auto-delete head branch after merge - */ - deleteHeadBranch: z.boolean().optional().default(true).describe('Delete head branch after merge'), -})); - -export type GitHubPullRequestConfig = z.infer; - -/** - * GitHub Actions Workflow Configuration - */ -export const GitHubActionsWorkflowSchema = lazySchema(() => z.object({ - /** - * Workflow name - */ - name: z.string().describe('Workflow name'), - - /** - * Workflow file path - */ - path: z.string().describe('Workflow file path (e.g., .github/workflows/ci.yml)'), - - /** - * Enable workflow - */ - enabled: z.boolean().optional().default(true).describe('Enable workflow'), - - /** - * Workflow triggers - */ - triggers: z.array(z.enum([ - 'push', - 'pull_request', - 'release', - 'schedule', - 'workflow_dispatch', - 'repository_dispatch', - ])).optional().describe('Workflow triggers'), - - /** - * Environment variables - */ - env: z.record(z.string(), z.string()).optional().describe('Environment variables'), - - /** - * Secrets required - */ - secrets: z.array(z.string()).optional().describe('Required secrets'), -})); - -export type GitHubActionsWorkflow = z.infer; - -/** - * GitHub Release Configuration - */ -export const GitHubReleaseConfigSchema = lazySchema(() => z.object({ - /** - * Tag name pattern - */ - tagPattern: z.string().optional().default('v*').describe('Tag name pattern (e.g., v*, release/*)'), - - /** - * Use semantic versioning - */ - semanticVersioning: z.boolean().optional().default(true).describe('Use semantic versioning'), - - /** - * Generate release notes automatically - */ - autoReleaseNotes: z.boolean().optional().default(true).describe('Generate release notes automatically'), - - /** - * Release name template - */ - releaseNameTemplate: z.string().optional().describe('Release name template'), - - /** - * Pre-release pattern - */ - preReleasePattern: z.string().optional().describe('Pre-release pattern (e.g., *-alpha, *-beta)'), - - /** - * Create draft releases - */ - draftByDefault: z.boolean().optional().default(false).describe('Create draft releases by default'), -})); - -export type GitHubReleaseConfig = z.infer; - -/** - * GitHub Issue Tracking Configuration - */ -export const GitHubIssueTrackingSchema = lazySchema(() => z.object({ - /** - * Enable issue tracking - */ - enabled: z.boolean().optional().default(true).describe('Enable issue tracking'), - - /** - * Default issue labels - */ - defaultLabels: z.array(z.string()).optional().describe('Default issue labels'), - - /** - * Issue template paths - */ - templatePaths: z.array(z.string()).optional().describe('Issue template paths'), - - /** - * Auto-assign issues - */ - autoAssign: z.boolean().optional().default(false).describe('Auto-assign issues'), - - /** - * Auto-close stale issues - */ - autoCloseStale: z.object({ - enabled: z.boolean().default(false), - daysBeforeStale: z.number().int().min(1).optional().default(60), - daysBeforeClose: z.number().int().min(1).optional().default(7), - staleLabel: z.string().optional().default('stale'), - }).optional().describe('Auto-close stale issues configuration'), -})); - -export type GitHubIssueTracking = z.infer; - -/** - * GitHub Connector Schema - * Complete GitHub integration configuration - */ -export const GitHubConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('saas'), - - /** - * GitHub provider type - */ - provider: GitHubProviderSchema.describe('GitHub provider'), - - /** - * GitHub API base URL - */ - baseUrl: z.string().url().optional().default('https://api.github.com').describe('GitHub API base URL'), - - /** - * Repositories to integrate - */ - repositories: z.array(GitHubRepositorySchema).describe('Repositories to manage'), - - /** - * Commit configuration - */ - commitConfig: GitHubCommitConfigSchema.optional().describe('Commit configuration'), - - /** - * Pull request configuration - */ - pullRequestConfig: GitHubPullRequestConfigSchema.optional().describe('Pull request configuration'), - - /** - * GitHub Actions workflows - */ - workflows: z.array(GitHubActionsWorkflowSchema).optional().describe('GitHub Actions workflows'), - - /** - * Release configuration - */ - releaseConfig: GitHubReleaseConfigSchema.optional().describe('Release configuration'), - - /** - * Issue tracking configuration - */ - issueTracking: GitHubIssueTrackingSchema.optional().describe('Issue tracking configuration'), - - /** - * Enable webhooks - */ - enableWebhooks: z.boolean().optional().default(true).describe('Enable GitHub webhooks'), - - /** - * Webhook events to subscribe - */ - webhookEvents: z.array(z.enum([ - 'push', - 'pull_request', - 'issues', - 'issue_comment', - 'release', - 'workflow_run', - 'deployment', - 'deployment_status', - 'check_run', - 'check_suite', - 'status', - ])).optional().describe('Webhook events to subscribe to'), -})); - -export type GitHubConnector = z.infer; - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: GitHub.com Connector Configuration - */ -export const githubPublicConnectorExample = { - name: 'github_public', - label: 'GitHub.com', - type: 'saas', - provider: 'github', - baseUrl: 'https://api.github.com', - - authentication: { - type: 'oauth2', - clientId: '${GITHUB_CLIENT_ID}', - clientSecret: '${GITHUB_CLIENT_SECRET}', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - scopes: ['repo', 'workflow', 'write:packages'], - }, - - repositories: [ - { - owner: 'objectstack-ai', - name: 'spec', - defaultBranch: 'main', - autoMerge: false, - branchProtection: { - requiredReviewers: 1, - requireStatusChecks: true, - enforceAdmins: false, - allowForcePushes: false, - allowDeletions: false, - }, - topics: ['objectstack', 'low-code', 'metadata-driven'], - }, - ], - - commitConfig: { - authorName: 'ObjectStack Bot', - authorEmail: 'bot@objectstack.ai', - signCommits: false, - useConventionalCommits: true, - }, - - pullRequestConfig: { - titleTemplate: '{{type}}: {{description}}', - defaultReviewers: ['team-lead'], - defaultLabels: ['automated', 'ai-generated'], - draftByDefault: false, - deleteHeadBranch: true, - }, - - workflows: [ - { - name: 'CI', - path: '.github/workflows/ci.yml', - enabled: true, - triggers: ['push', 'pull_request'], - }, - { - name: 'Release', - path: '.github/workflows/release.yml', - enabled: true, - triggers: ['release'], - }, - ], - - releaseConfig: { - tagPattern: 'v*', - semanticVersioning: true, - autoReleaseNotes: true, - releaseNameTemplate: 'Release {{version}}', - draftByDefault: false, - }, - - issueTracking: { - enabled: true, - defaultLabels: ['needs-triage'], - autoAssign: false, - autoCloseStale: { - enabled: true, - daysBeforeStale: 60, - daysBeforeClose: 7, - staleLabel: 'stale', - }, - }, - - enableWebhooks: true, - webhookEvents: ['push', 'pull_request', 'release', 'workflow_run'], - - status: 'active', - enabled: true, -}; - -/** - * Example: GitHub Enterprise Connector Configuration - */ -export const githubEnterpriseConnectorExample = { - name: 'github_enterprise', - label: 'GitHub Enterprise', - type: 'saas', - provider: 'github_enterprise', - baseUrl: 'https://github.enterprise.com/api/v3', - - authentication: { - type: 'oauth2', - clientId: '${GITHUB_ENTERPRISE_CLIENT_ID}', - clientSecret: '${GITHUB_ENTERPRISE_CLIENT_SECRET}', - authorizationUrl: 'https://github.enterprise.com/login/oauth/authorize', - tokenUrl: 'https://github.enterprise.com/login/oauth/access_token', - scopes: ['repo', 'admin:org', 'workflow'], - }, - - repositories: [ - { - owner: 'enterprise-org', - name: 'internal-app', - defaultBranch: 'develop', - autoMerge: true, - branchProtection: { - requiredReviewers: 2, - requireStatusChecks: true, - enforceAdmins: true, - allowForcePushes: false, - allowDeletions: false, - }, - }, - ], - - commitConfig: { - authorName: 'CI Bot', - authorEmail: 'ci-bot@enterprise.com', - signCommits: true, - useConventionalCommits: true, - }, - - pullRequestConfig: { - titleTemplate: '[{{branch}}] {{description}}', - bodyTemplate: `## Changes\n\n{{changes}}\n\n## Testing\n\n{{testing}}`, - defaultReviewers: ['tech-lead', 'security-team'], - defaultLabels: ['automated'], - draftByDefault: true, - deleteHeadBranch: true, - }, - - releaseConfig: { - tagPattern: 'release/*', - semanticVersioning: true, - autoReleaseNotes: true, - preReleasePattern: '*-rc*', - draftByDefault: true, - }, - - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/connector/message-queue.test.ts b/packages/spec/src/integration/connector/message-queue.test.ts deleted file mode 100644 index b3c84ea2d6..0000000000 --- a/packages/spec/src/integration/connector/message-queue.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - MessageQueueProviderSchema, - MessageFormatSchema, - AckModeSchema, - DeliveryGuaranteeSchema, - ConsumerConfigSchema, - ProducerConfigSchema, - DlqConfigSchema, - TopicQueueSchema, - MessageQueueConnectorSchema, -} from './message-queue.zod'; - -const baseAuth = { type: 'none' as const }; - -const minimalTopic = { - name: 'order_events', - label: 'Order Events', - topicName: 'orders', -}; - -const minimalConnector = { - name: 'kafka_main', - label: 'Kafka Main', - type: 'message_queue' as const, - provider: 'kafka' as const, - authentication: baseAuth, - brokerConfig: { - brokers: ['localhost:9092'], - }, - topics: [minimalTopic], -}; - -describe('MessageQueueProviderSchema', () => { - it('should accept all valid providers', () => { - const providers = ['rabbitmq', 'kafka', 'redis_pubsub', 'redis_streams', 'aws_sqs', 'aws_sns', 'google_pubsub', 'azure_service_bus', 'azure_event_hubs', 'nats', 'pulsar', 'activemq', 'custom']; - for (const p of providers) { - expect(MessageQueueProviderSchema.parse(p)).toBe(p); - } - }); - - it('should reject invalid provider', () => { - expect(() => MessageQueueProviderSchema.parse('zeromq')).toThrow(); - }); -}); - -describe('MessageFormatSchema', () => { - it('should accept valid formats', () => { - for (const f of ['json', 'xml', 'protobuf', 'avro', 'text', 'binary']) { - expect(MessageFormatSchema.parse(f)).toBe(f); - } - }); - - it('should reject invalid format', () => { - expect(() => MessageFormatSchema.parse('yaml')).toThrow(); - }); -}); - -describe('AckModeSchema', () => { - it('should accept valid modes', () => { - for (const m of ['auto', 'manual', 'client']) { - expect(AckModeSchema.parse(m)).toBe(m); - } - }); - - it('should reject invalid mode', () => { - expect(() => AckModeSchema.parse('batch')).toThrow(); - }); -}); - -describe('DeliveryGuaranteeSchema', () => { - it('should accept valid guarantees', () => { - for (const g of ['at_most_once', 'at_least_once', 'exactly_once']) { - expect(DeliveryGuaranteeSchema.parse(g)).toBe(g); - } - }); - - it('should reject invalid guarantee', () => { - expect(() => DeliveryGuaranteeSchema.parse('best_effort')).toThrow(); - }); -}); - -describe('ConsumerConfigSchema', () => { - it('should apply defaults', () => { - const result = ConsumerConfigSchema.parse({}); - expect(result.enabled).toBe(true); - expect(result.concurrency).toBe(1); - expect(result.prefetchCount).toBe(10); - expect(result.ackMode).toBe('manual'); - expect(result.autoCommit).toBe(false); - expect(result.autoCommitIntervalMs).toBe(5000); - expect(result.sessionTimeoutMs).toBe(30000); - }); - - it('should accept custom values', () => { - const result = ConsumerConfigSchema.parse({ - consumerGroup: 'my-group', - concurrency: 10, - prefetchCount: 100, - ackMode: 'auto', - rebalanceTimeoutMs: 5000, - }); - expect(result.consumerGroup).toBe('my-group'); - expect(result.concurrency).toBe(10); - }); - - it('should reject concurrency out of range', () => { - expect(() => ConsumerConfigSchema.parse({ concurrency: 0 })).toThrow(); - expect(() => ConsumerConfigSchema.parse({ concurrency: 101 })).toThrow(); - }); - - it('should reject prefetchCount out of range', () => { - expect(() => ConsumerConfigSchema.parse({ prefetchCount: 0 })).toThrow(); - expect(() => ConsumerConfigSchema.parse({ prefetchCount: 1001 })).toThrow(); - }); -}); - -describe('ProducerConfigSchema', () => { - it('should apply defaults', () => { - const result = ProducerConfigSchema.parse({}); - expect(result.enabled).toBe(true); - expect(result.acks).toBe('all'); - expect(result.compressionType).toBe('none'); - expect(result.idempotence).toBe(true); - expect(result.transactional).toBe(false); - }); - - it('should accept custom values', () => { - const result = ProducerConfigSchema.parse({ - acks: '1', - compressionType: 'snappy', - batchSize: 32768, - lingerMs: 10, - }); - expect(result.acks).toBe('1'); - expect(result.compressionType).toBe('snappy'); - }); - - it('should reject invalid acks', () => { - expect(() => ProducerConfigSchema.parse({ acks: '2' })).toThrow(); - }); - - it('should reject invalid compressionType', () => { - expect(() => ProducerConfigSchema.parse({ compressionType: 'brotli' })).toThrow(); - }); -}); - -describe('DlqConfigSchema', () => { - it('should accept valid DLQ config', () => { - const result = DlqConfigSchema.parse({ queueName: 'my-dlq' }); - expect(result.enabled).toBe(false); - expect(result.maxRetries).toBe(3); - expect(result.retryDelayMs).toBe(60000); - }); - - it('should reject missing queueName', () => { - expect(() => DlqConfigSchema.parse({})).toThrow(); - }); - - it('should reject maxRetries out of range', () => { - expect(() => DlqConfigSchema.parse({ queueName: 'dlq', maxRetries: -1 })).toThrow(); - expect(() => DlqConfigSchema.parse({ queueName: 'dlq', maxRetries: 101 })).toThrow(); - }); -}); - -describe('TopicQueueSchema', () => { - it('should accept minimal topic', () => { - const result = TopicQueueSchema.parse(minimalTopic); - expect(result.enabled).toBe(true); - expect(result.mode).toBe('both'); - expect(result.messageFormat).toBe('json'); - }); - - it('should accept topic with all options', () => { - const data = { - ...minimalTopic, - enabled: false, - mode: 'consumer', - messageFormat: 'avro', - partitions: 10, - replicationFactor: 3, - consumerConfig: { consumerGroup: 'grp' }, - producerConfig: { acks: '1' }, - dlqConfig: { queueName: 'dlq' }, - routingKey: 'order.*', - messageFilter: { headers: { type: 'order' } }, - }; - expect(() => TopicQueueSchema.parse(data)).not.toThrow(); - }); - - it('should reject non-snake_case name', () => { - expect(() => TopicQueueSchema.parse({ ...minimalTopic, name: 'OrderEvents' })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => TopicQueueSchema.parse({ name: 'topic' })).toThrow(); - }); -}); - -describe('MessageQueueConnectorSchema', () => { - it('should accept minimal valid connector', () => { - expect(() => MessageQueueConnectorSchema.parse(minimalConnector)).not.toThrow(); - }); - - it('should apply defaults', () => { - const result = MessageQueueConnectorSchema.parse(minimalConnector); - expect(result.deliveryGuarantee).toBe('at_least_once'); - expect(result.preserveOrder).toBe(true); - expect(result.enableMetrics).toBe(true); - expect(result.enableTracing).toBe(false); - expect(result.enabled).toBe(true); - }); - - it('should accept full connector', () => { - const full = { - ...minimalConnector, - brokerConfig: { - brokers: ['broker1:9092', 'broker2:9092'], - clientId: 'my-client', - connectionTimeoutMs: 5000, - requestTimeoutMs: 5000, - }, - deliveryGuarantee: 'exactly_once', - sslConfig: { enabled: true, rejectUnauthorized: true }, - saslConfig: { mechanism: 'scram-sha-256', username: 'u', password: 'p' }, - schemaRegistry: { url: 'https://registry.example.com' }, - preserveOrder: false, - enableMetrics: false, - enableTracing: true, - }; - expect(() => MessageQueueConnectorSchema.parse(full)).not.toThrow(); - }); - - it('should reject wrong type literal', () => { - expect(() => MessageQueueConnectorSchema.parse({ ...minimalConnector, type: 'database' })).toThrow(); - }); - - it('should reject invalid provider', () => { - expect(() => MessageQueueConnectorSchema.parse({ ...minimalConnector, provider: 'unknown' })).toThrow(); - }); - - it('should reject missing brokerConfig', () => { - const { brokerConfig: _, ...noConfig } = minimalConnector; - expect(() => MessageQueueConnectorSchema.parse(noConfig)).toThrow(); - }); - - it('should reject missing topics', () => { - const { topics: _, ...noTopics } = minimalConnector; - expect(() => MessageQueueConnectorSchema.parse(noTopics)).toThrow(); - }); - - it('should reject invalid schemaRegistry url', () => { - expect(() => MessageQueueConnectorSchema.parse({ - ...minimalConnector, - schemaRegistry: { url: 'not-a-url' }, - })).toThrow(); - }); -}); diff --git a/packages/spec/src/integration/connector/message-queue.zod.ts b/packages/spec/src/integration/connector/message-queue.zod.ts deleted file mode 100644 index b285e7fd03..0000000000 --- a/packages/spec/src/integration/connector/message-queue.zod.ts +++ /dev/null @@ -1,501 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { - ConnectorSchema, -} from '../connector.zod'; - -/** - * Message Queue Connector Protocol Template - * - * Specialized connector for message queue systems (RabbitMQ, Kafka, SQS, etc.) - * Extends the base connector with message queue-specific features like topics, - * consumer groups, and message acknowledgment patterns. - */ - -/** - * Message Queue Provider Types - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const MessageQueueProviderSchema = lazySchema(() => z.enum([ - 'rabbitmq', // RabbitMQ - 'kafka', // Apache Kafka - 'redis_pubsub', // Redis Pub/Sub - 'redis_streams', // Redis Streams - 'aws_sqs', // Amazon SQS - 'aws_sns', // Amazon SNS - 'google_pubsub', // Google Cloud Pub/Sub - 'azure_service_bus', // Azure Service Bus - 'azure_event_hubs', // Azure Event Hubs - 'nats', // NATS - 'pulsar', // Apache Pulsar - 'activemq', // Apache ActiveMQ - 'custom', // Custom message queue -]).describe('Message queue provider type')); - -export type MessageQueueProvider = z.infer; - -/** - * Message Format - */ -export const MessageFormatSchema = lazySchema(() => z.enum([ - 'json', - 'xml', - 'protobuf', - 'avro', - 'text', - 'binary', -]).describe('Message format/serialization')); - -export type MessageFormat = z.infer; - -/** - * Message Acknowledgment Mode - */ -export const AckModeSchema = lazySchema(() => z.enum([ - 'auto', // Auto-acknowledge - 'manual', // Manual acknowledge after processing - 'client', // Client-controlled acknowledge -]).describe('Message acknowledgment mode')); - -export type AckMode = z.infer; - -/** - * Delivery Guarantee - */ -export const DeliveryGuaranteeSchema = lazySchema(() => z.enum([ - 'at_most_once', // Fire and forget - 'at_least_once', // May deliver duplicates - 'exactly_once', // Guaranteed exactly once delivery -]).describe('Message delivery guarantee')); - -export type DeliveryGuarantee = z.infer; - -/** - * Consumer Configuration - */ -export const ConsumerConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().optional().default(true).describe('Enable consumer'), - - consumerGroup: z.string().optional().describe('Consumer group ID'), - - concurrency: z.number().min(1).max(100).optional().default(1).describe('Number of concurrent consumers'), - - prefetchCount: z.number().min(1).max(1000).optional().default(10).describe('Prefetch count'), - - ackMode: AckModeSchema.optional().default('manual'), - - autoCommit: z.boolean().optional().default(false).describe('Auto-commit offsets'), - - autoCommitIntervalMs: z.number().min(100).optional().default(5000).describe('Auto-commit interval in ms'), - - sessionTimeoutMs: z.number().min(1000).optional().default(30000).describe('Session timeout in ms'), - - rebalanceTimeoutMs: z.number().min(1000).optional().describe('Rebalance timeout in ms'), -})); - -export type ConsumerConfig = z.infer; - -/** - * Producer Configuration - */ -export const ProducerConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().optional().default(true).describe('Enable producer'), - - acks: z.enum(['0', '1', 'all']).optional().default('all').describe('Acknowledgment level'), - - compressionType: z.enum(['none', 'gzip', 'snappy', 'lz4', 'zstd']).optional().default('none').describe('Compression type'), - - batchSize: z.number().min(1).optional().default(16384).describe('Batch size in bytes'), - - lingerMs: z.number().min(0).optional().default(0).describe('Linger time in ms'), - - maxInFlightRequests: z.number().min(1).optional().default(5).describe('Max in-flight requests'), - - idempotence: z.boolean().optional().default(true).describe('Enable idempotent producer'), - - transactional: z.boolean().optional().default(false).describe('Enable transactional producer'), - - transactionTimeoutMs: z.number().min(1000).optional().describe('Transaction timeout in ms'), -})); - -export type ProducerConfig = z.infer; - -/** - * Dead Letter Queue Configuration - */ -export const DlqConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().optional().default(false).describe('Enable DLQ'), - - queueName: z.string().describe('Dead letter queue/topic name'), - - maxRetries: z.number().min(0).max(100).optional().default(3).describe('Max retries before DLQ'), - - retryDelayMs: z.number().min(0).optional().default(60000).describe('Retry delay in ms'), -})); - -export type DlqConfig = z.infer; - -/** - * Topic/Queue Configuration - */ -export const TopicQueueSchema = lazySchema(() => z.object({ - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Topic/queue identifier in ObjectStack (snake_case)'), - label: z.string().describe('Display label'), - topicName: z.string().describe('Actual topic/queue name in message queue system'), - enabled: z.boolean().optional().default(true).describe('Enable sync for this topic/queue'), - - /** - * Consumer or Producer - */ - mode: z.enum(['consumer', 'producer', 'both']).optional().default('both').describe('Consumer, producer, or both'), - - /** - * Message format - */ - messageFormat: MessageFormatSchema.optional().default('json'), - - /** - * Partition/shard configuration - */ - partitions: z.number().min(1).optional().describe('Number of partitions (for Kafka)'), - - /** - * Replication factor - */ - replicationFactor: z.number().min(1).optional().describe('Replication factor (for Kafka)'), - - /** - * Consumer configuration - */ - consumerConfig: ConsumerConfigSchema.optional().describe('Consumer-specific configuration'), - - /** - * Producer configuration - */ - producerConfig: ProducerConfigSchema.optional().describe('Producer-specific configuration'), - - /** - * Dead letter queue configuration - */ - dlqConfig: DlqConfigSchema.optional().describe('Dead letter queue configuration'), - - /** - * Message routing key (for RabbitMQ) - */ - routingKey: z.string().optional().describe('Routing key pattern'), - - /** - * Message filter - */ - messageFilter: z.object({ - headers: z.record(z.string(), z.string()).optional().describe('Filter by message headers'), - attributes: z.record(z.string(), z.unknown()).optional().describe('Filter by message attributes'), - }).optional().describe('Message filter criteria'), -})); - -export type TopicQueue = z.infer; - -/** - * Message Queue Connector Configuration Schema - */ -export const MessageQueueConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('message_queue'), - - /** - * Message queue provider - */ - provider: MessageQueueProviderSchema.describe('Message queue provider type'), - - /** - * Broker configuration - */ - brokerConfig: z.object({ - brokers: z.array(z.string()).describe('Broker addresses (host:port)'), - clientId: z.string().optional().describe('Client ID'), - connectionTimeoutMs: z.number().min(1000).optional().default(30000).describe('Connection timeout in ms'), - requestTimeoutMs: z.number().min(1000).optional().default(30000).describe('Request timeout in ms'), - }).describe('Broker connection configuration'), - - /** - * Topics/queues to sync - */ - topics: z.array(TopicQueueSchema).describe('Topics/queues to sync'), - - /** - * Delivery guarantee - */ - deliveryGuarantee: DeliveryGuaranteeSchema.optional().default('at_least_once'), - - /** - * SSL/TLS configuration - */ - sslConfig: z.object({ - enabled: z.boolean().optional().default(false).describe('Enable SSL/TLS'), - rejectUnauthorized: z.boolean().optional().default(true).describe('Reject unauthorized certificates'), - ca: z.string().optional().describe('CA certificate'), - cert: z.string().optional().describe('Client certificate'), - key: z.string().optional().describe('Client private key'), - }).optional().describe('SSL/TLS configuration'), - - /** - * SASL authentication (for Kafka) - */ - saslConfig: z.object({ - mechanism: z.enum(['plain', 'scram-sha-256', 'scram-sha-512', 'aws']).describe('SASL mechanism'), - username: z.string().optional().describe('SASL username'), - password: z.string().optional().describe('SASL password'), - }).optional().describe('SASL authentication configuration'), - - /** - * Schema registry configuration (for Kafka/Avro) - */ - schemaRegistry: z.object({ - url: z.string().url().describe('Schema registry URL'), - auth: z.object({ - username: z.string().optional(), - password: z.string().optional(), - }).optional(), - }).optional().describe('Schema registry configuration'), - - /** - * Message ordering - */ - preserveOrder: z.boolean().optional().default(true).describe('Preserve message ordering'), - - /** - * Enable metrics - */ - enableMetrics: z.boolean().optional().default(true).describe('Enable message queue metrics'), - - /** - * Enable distributed tracing - */ - enableTracing: z.boolean().optional().default(false).describe('Enable distributed tracing'), -})); - -export type MessageQueueConnector = z.infer; - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: Apache Kafka Connector Configuration - */ -export const kafkaConnectorExample = { - name: 'kafka_production', - label: 'Production Kafka Cluster', - type: 'message_queue', - provider: 'kafka', - authentication: { - type: 'none', - }, - brokerConfig: { - brokers: ['kafka-1.example.com:9092', 'kafka-2.example.com:9092', 'kafka-3.example.com:9092'], - clientId: 'objectstack-client', - connectionTimeoutMs: 30000, - requestTimeoutMs: 30000, - }, - topics: [ - { - name: 'order_events', - label: 'Order Events', - topicName: 'orders', - enabled: true, - mode: 'consumer', - messageFormat: 'json', - partitions: 10, - replicationFactor: 3, - consumerConfig: { - enabled: true, - consumerGroup: 'objectstack-consumer-group', - concurrency: 5, - prefetchCount: 100, - ackMode: 'manual', - autoCommit: false, - sessionTimeoutMs: 30000, - }, - dlqConfig: { - enabled: true, - queueName: 'orders-dlq', - maxRetries: 3, - retryDelayMs: 60000, - }, - }, - { - name: 'user_activity', - label: 'User Activity', - topicName: 'user-activity', - enabled: true, - mode: 'producer', - messageFormat: 'json', - partitions: 5, - replicationFactor: 3, - producerConfig: { - enabled: true, - acks: 'all', - compressionType: 'snappy', - batchSize: 16384, - lingerMs: 10, - maxInFlightRequests: 5, - idempotence: true, - }, - }, - ], - deliveryGuarantee: 'at_least_once', - saslConfig: { - mechanism: 'scram-sha-256', - username: '${KAFKA_USERNAME}', - password: '${KAFKA_PASSWORD}', - }, - sslConfig: { - enabled: true, - rejectUnauthorized: true, - }, - preserveOrder: true, - enableMetrics: true, - enableTracing: true, - status: 'active', - enabled: true, -}; - -/** - * Example: RabbitMQ Connector Configuration - */ -export const rabbitmqConnectorExample = { - name: 'rabbitmq_events', - label: 'RabbitMQ Event Bus', - type: 'message_queue', - provider: 'rabbitmq', - authentication: { - type: 'basic', - username: '${RABBITMQ_USERNAME}', - password: '${RABBITMQ_PASSWORD}', - }, - brokerConfig: { - brokers: ['amqp://rabbitmq.example.com:5672'], - clientId: 'objectstack-rabbitmq-client', - }, - topics: [ - { - name: 'notifications', - label: 'Notifications', - topicName: 'notifications', - enabled: true, - mode: 'both', - messageFormat: 'json', - routingKey: 'notification.*', - consumerConfig: { - enabled: true, - prefetchCount: 10, - ackMode: 'manual', - }, - producerConfig: { - enabled: true, - }, - dlqConfig: { - enabled: true, - queueName: 'notifications-dlq', - maxRetries: 3, - retryDelayMs: 30000, - }, - }, - ], - deliveryGuarantee: 'at_least_once', - status: 'active', - enabled: true, -}; - -/** - * Example: AWS SQS Connector Configuration - */ -export const sqsConnectorExample = { - name: 'aws_sqs_queue', - label: 'AWS SQS Queue', - type: 'message_queue', - provider: 'aws_sqs', - authentication: { - type: 'api_key', - apiKey: '${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}', - headerName: 'Authorization', - }, - brokerConfig: { - brokers: ['https://sqs.us-east-1.amazonaws.com'], - }, - topics: [ - { - name: 'task_queue', - label: 'Task Queue', - topicName: 'task-queue', - enabled: true, - mode: 'consumer', - messageFormat: 'json', - consumerConfig: { - enabled: true, - concurrency: 10, - prefetchCount: 10, - ackMode: 'manual', - }, - dlqConfig: { - enabled: true, - queueName: 'task-queue-dlq', - maxRetries: 3, - retryDelayMs: 120000, - }, - }, - ], - deliveryGuarantee: 'at_least_once', - retryConfig: { - strategy: 'exponential_backoff', - maxAttempts: 3, - initialDelayMs: 1000, - maxDelayMs: 60000, - backoffMultiplier: 2, - }, - status: 'active', - enabled: true, -}; - -/** - * Example: Google Cloud Pub/Sub Connector Configuration - */ -export const pubsubConnectorExample = { - name: 'gcp_pubsub', - label: 'Google Cloud Pub/Sub', - type: 'message_queue', - provider: 'google_pubsub', - authentication: { - type: 'oauth2', - clientId: '${GCP_CLIENT_ID}', - clientSecret: '${GCP_CLIENT_SECRET}', - authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - grantType: 'client_credentials', - scopes: ['https://www.googleapis.com/auth/pubsub'], - }, - brokerConfig: { - brokers: ['pubsub.googleapis.com'], - }, - topics: [ - { - name: 'analytics_events', - label: 'Analytics Events', - topicName: 'projects/my-project/topics/analytics-events', - enabled: true, - mode: 'both', - messageFormat: 'json', - consumerConfig: { - enabled: true, - consumerGroup: 'objectstack-subscription', - concurrency: 5, - prefetchCount: 100, - ackMode: 'manual', - }, - }, - ], - deliveryGuarantee: 'at_least_once', - enableMetrics: true, - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/connector/saas.test.ts b/packages/spec/src/integration/connector/saas.test.ts deleted file mode 100644 index c0be38fd3e..0000000000 --- a/packages/spec/src/integration/connector/saas.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - SaasProviderSchema, - ApiVersionConfigSchema, - SaasObjectTypeSchema, - SaasConnectorSchema, -} from './saas.zod'; - -const baseAuth = { type: 'none' as const }; - -const minimalObjectType = { - name: 'account', - label: 'Account', - apiName: 'Account', -}; - -const minimalConnector = { - name: 'sf_prod', - label: 'Salesforce Prod', - type: 'saas' as const, - provider: 'salesforce' as const, - authentication: baseAuth, - baseUrl: 'https://api.example.com', - objectTypes: [minimalObjectType], -}; - -describe('SaasProviderSchema', () => { - it('should accept all valid providers', () => { - const providers = ['salesforce', 'hubspot', 'stripe', 'shopify', 'zendesk', 'intercom', 'mailchimp', 'slack', 'microsoft_dynamics', 'servicenow', 'netsuite', 'custom']; - for (const p of providers) { - expect(SaasProviderSchema.parse(p)).toBe(p); - } - }); - - it('should reject invalid provider', () => { - expect(() => SaasProviderSchema.parse('quickbooks')).toThrow(); - }); -}); - -describe('ApiVersionConfigSchema', () => { - it('should accept valid config', () => { - const result = ApiVersionConfigSchema.parse({ version: 'v59.0' }); - expect(result.version).toBe('v59.0'); - expect(result.isDefault).toBe(false); - }); - - it('should accept full config', () => { - const data = { - version: '2023-10-01', - isDefault: true, - deprecationDate: '2024-01-01', - sunsetDate: '2024-06-01', - }; - const result = ApiVersionConfigSchema.parse(data); - expect(result.isDefault).toBe(true); - expect(result.deprecationDate).toBe('2024-01-01'); - }); - - it('should reject missing version', () => { - expect(() => ApiVersionConfigSchema.parse({})).toThrow(); - }); -}); - -describe('SaasObjectTypeSchema', () => { - it('should accept minimal object type', () => { - const result = SaasObjectTypeSchema.parse(minimalObjectType); - expect(result.enabled).toBe(true); - expect(result.supportsCreate).toBe(true); - expect(result.supportsUpdate).toBe(true); - expect(result.supportsDelete).toBe(true); - }); - - it('should accept object type with all fields', () => { - const data = { - ...minimalObjectType, - enabled: false, - supportsCreate: false, - supportsUpdate: false, - supportsDelete: false, - fieldMappings: [{ source: 'Name', target: 'name' }], - }; - expect(() => SaasObjectTypeSchema.parse(data)).not.toThrow(); - }); - - it('should reject non-snake_case name', () => { - expect(() => SaasObjectTypeSchema.parse({ ...minimalObjectType, name: 'Account' })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => SaasObjectTypeSchema.parse({ name: 'acct' })).toThrow(); - }); -}); - -describe('SaasConnectorSchema', () => { - it('should accept minimal valid connector', () => { - expect(() => SaasConnectorSchema.parse(minimalConnector)).not.toThrow(); - }); - - it('should apply defaults', () => { - const result = SaasConnectorSchema.parse(minimalConnector); - expect(result.enabled).toBe(true); - expect(result.status).toBe('inactive'); - }); - - it('should accept full connector', () => { - const full = { - ...minimalConnector, - apiVersion: { version: 'v59.0', isDefault: true }, - oauthSettings: { - scopes: ['api', 'refresh_token'], - refreshTokenUrl: 'https://login.example.com/token', - revokeTokenUrl: 'https://login.example.com/revoke', - autoRefresh: true, - }, - paginationConfig: { - type: 'cursor', - defaultPageSize: 50, - maxPageSize: 500, - }, - sandboxConfig: { - enabled: true, - baseUrl: 'https://sandbox.example.com', - }, - customHeaders: { 'X-Custom': 'value' }, - }; - expect(() => SaasConnectorSchema.parse(full)).not.toThrow(); - }); - - it('should reject wrong type literal', () => { - expect(() => SaasConnectorSchema.parse({ ...minimalConnector, type: 'database' })).toThrow(); - }); - - it('should reject invalid baseUrl', () => { - expect(() => SaasConnectorSchema.parse({ ...minimalConnector, baseUrl: 'not-a-url' })).toThrow(); - }); - - it('should reject invalid provider', () => { - expect(() => SaasConnectorSchema.parse({ ...minimalConnector, provider: 'unknown' })).toThrow(); - }); - - it('should reject missing objectTypes', () => { - const { objectTypes: _, ...noTypes } = minimalConnector; - expect(() => SaasConnectorSchema.parse(noTypes)).toThrow(); - }); - - it('should reject missing baseUrl', () => { - const { baseUrl: _, ...noUrl } = minimalConnector; - expect(() => SaasConnectorSchema.parse(noUrl)).toThrow(); - }); - - it('should reject invalid paginationConfig', () => { - expect(() => SaasConnectorSchema.parse({ - ...minimalConnector, - paginationConfig: { type: 'cursor', defaultPageSize: 0 }, - })).toThrow(); - }); - - it('should reject invalid oauthSettings URLs', () => { - expect(() => SaasConnectorSchema.parse({ - ...minimalConnector, - oauthSettings: { scopes: ['api'], refreshTokenUrl: 'not-a-url' }, - })).toThrow(); - }); -}); diff --git a/packages/spec/src/integration/connector/saas.zod.ts b/packages/spec/src/integration/connector/saas.zod.ts deleted file mode 100644 index 2aaac3a5fa..0000000000 --- a/packages/spec/src/integration/connector/saas.zod.ts +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { - ConnectorSchema, - FieldMappingSchema, -} from '../connector.zod'; - -/** - * SaaS Connector Protocol Template - * - * Specialized connector for SaaS applications (Salesforce, HubSpot, Stripe, etc.) - * Extends the base connector with SaaS-specific features like OAuth flows, - * object type discovery, and API version management. - */ - -/** - * SaaS Provider Types - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const SaasProviderSchema = lazySchema(() => z.enum([ - 'salesforce', - 'hubspot', - 'stripe', - 'shopify', - 'zendesk', - 'intercom', - 'mailchimp', - 'slack', - 'microsoft_dynamics', - 'servicenow', - 'netsuite', - 'custom', -]).describe('SaaS provider type')); - -export type SaasProvider = z.infer; - -/** - * API Version Configuration - */ -export const ApiVersionConfigSchema = lazySchema(() => z.object({ - version: z.string().describe('API version (e.g., "v2", "2023-10-01")'), - isDefault: z.boolean().default(false).describe('Is this the default version'), - deprecationDate: z.string().optional().describe('API version deprecation date (ISO 8601)'), - sunsetDate: z.string().optional().describe('API version sunset date (ISO 8601)'), -})); - -export type ApiVersionConfig = z.infer; - -/** - * SaaS Object Type Schema - * Represents a syncable entity in the SaaS system (e.g., Account, Contact, Deal) - */ -export const SaasObjectTypeSchema = lazySchema(() => z.object({ - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Object type name (snake_case)'), - label: z.string().describe('Display label'), - apiName: z.string().describe('API name in external system'), - enabled: z.boolean().default(true).describe('Enable sync for this object'), - supportsCreate: z.boolean().default(true).describe('Supports record creation'), - supportsUpdate: z.boolean().default(true).describe('Supports record updates'), - supportsDelete: z.boolean().default(true).describe('Supports record deletion'), - fieldMappings: z.array(FieldMappingSchema).optional().describe('Object-specific field mappings'), -})); - -export type SaasObjectType = z.infer; - -/** - * SaaS Connector Configuration Schema - */ -export const SaasConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('saas'), - - /** - * SaaS provider - */ - provider: SaasProviderSchema.describe('SaaS provider type'), - - /** - * Base URL for API requests - */ - baseUrl: z.string().url().describe('API base URL'), - - /** - * API version configuration - */ - apiVersion: ApiVersionConfigSchema.optional().describe('API version configuration'), - - /** - * Supported object types to sync - */ - objectTypes: z.array(SaasObjectTypeSchema).describe('Syncable object types'), - - /** - * OAuth-specific settings - */ - oauthSettings: z.object({ - scopes: z.array(z.string()).describe('Required OAuth scopes'), - refreshTokenUrl: z.string().url().optional().describe('Token refresh endpoint'), - revokeTokenUrl: z.string().url().optional().describe('Token revocation endpoint'), - autoRefresh: z.boolean().default(true).describe('Automatically refresh expired tokens'), - }).optional().describe('OAuth-specific configuration'), - - /** - * Pagination settings - */ - paginationConfig: z.object({ - type: z.enum(['cursor', 'offset', 'page']).default('cursor').describe('Pagination type'), - defaultPageSize: z.number().min(1).max(1000).default(100).describe('Default page size'), - maxPageSize: z.number().min(1).max(10000).default(1000).describe('Maximum page size'), - }).optional().describe('Pagination configuration'), - - /** - * Sandbox/test environment settings - */ - sandboxConfig: z.object({ - enabled: z.boolean().default(false).describe('Use sandbox environment'), - baseUrl: z.string().url().optional().describe('Sandbox API base URL'), - }).optional().describe('Sandbox environment configuration'), - - /** - * Custom request headers - */ - customHeaders: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers for all requests'), -})); - -export type SaasConnector = z.infer; -export type SaaSConnector = SaasConnector; // Alias for alternative capitalization - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: Salesforce Connector Configuration - */ -export const salesforceConnectorExample = { - name: 'salesforce_production', - label: 'Salesforce Production', - type: 'saas', - provider: 'salesforce', - baseUrl: 'https://example.my.salesforce.com', - apiVersion: { - version: 'v59.0', - isDefault: true, - }, - authentication: { - type: 'oauth2', - clientId: '${SALESFORCE_CLIENT_ID}', - clientSecret: '${SALESFORCE_CLIENT_SECRET}', - authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', - tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - grantType: 'authorization_code', - scopes: ['api', 'refresh_token', 'offline_access'], - }, - objectTypes: [ - { - name: 'account', - label: 'Account', - apiName: 'Account', - enabled: true, - supportsCreate: true, - supportsUpdate: true, - supportsDelete: true, - }, - { - name: 'contact', - label: 'Contact', - apiName: 'Contact', - enabled: true, - supportsCreate: true, - supportsUpdate: true, - supportsDelete: true, - }, - ], - syncConfig: { - strategy: 'incremental', - direction: 'bidirectional', - schedule: '0 */6 * * *', // Every 6 hours - realtimeSync: true, - conflictResolution: 'latest_wins', - batchSize: 200, - deleteMode: 'soft_delete', - }, - rateLimitConfig: { - strategy: 'token_bucket', - maxRequests: 100, - windowSeconds: 20, - respectUpstreamLimits: true, - }, - retryConfig: { - strategy: 'exponential_backoff', - maxAttempts: 3, - initialDelayMs: 1000, - maxDelayMs: 30000, - backoffMultiplier: 2, - retryableStatusCodes: [408, 429, 500, 502, 503, 504], - retryOnNetworkError: true, - jitter: true, - }, - status: 'active', - enabled: true, -}; - -/** - * Example: HubSpot Connector Configuration - */ -export const hubspotConnectorExample = { - name: 'hubspot_crm', - label: 'HubSpot CRM', - type: 'saas', - provider: 'hubspot', - baseUrl: 'https://api.hubapi.com', - authentication: { - type: 'api_key', - apiKey: '${HUBSPOT_API_KEY}', - headerName: 'Authorization', - }, - objectTypes: [ - { - name: 'company', - label: 'Company', - apiName: 'companies', - enabled: true, - supportsCreate: true, - supportsUpdate: true, - supportsDelete: true, - }, - { - name: 'deal', - label: 'Deal', - apiName: 'deals', - enabled: true, - supportsCreate: true, - supportsUpdate: true, - supportsDelete: true, - }, - ], - syncConfig: { - strategy: 'incremental', - direction: 'import', - schedule: '0 */4 * * *', // Every 4 hours - conflictResolution: 'source_wins', - batchSize: 100, - }, - rateLimitConfig: { - strategy: 'token_bucket', - maxRequests: 100, - windowSeconds: 10, - }, - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/connector/vercel.test.ts b/packages/spec/src/integration/connector/vercel.test.ts deleted file mode 100644 index 43bc7b7bd2..0000000000 --- a/packages/spec/src/integration/connector/vercel.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - VercelConnectorSchema, - VercelProjectSchema, - GitRepositoryConfigSchema, - BuildConfigSchema, - DeploymentConfigSchema, - DomainConfigSchema, - EnvironmentVariablesSchema, - EdgeFunctionConfigSchema, - vercelNextJsConnectorExample, - vercelStaticSiteConnectorExample, - type VercelConnector, -} from './vercel.zod'; - -describe('GitRepositoryConfigSchema', () => { - it('should accept minimal git repo config', () => { - const config = { - type: 'github' as const, - repo: 'owner/repo', - }; - - const result = GitRepositoryConfigSchema.parse(config); - expect(result.productionBranch).toBe('main'); - expect(result.autoDeployProduction).toBe(true); - expect(result.autoDeployPreview).toBe(true); - }); - - it('should accept all git provider types', () => { - const providers = ['github', 'gitlab', 'bitbucket'] as const; - - providers.forEach(type => { - const config = { type, repo: 'owner/repo' }; - expect(() => GitRepositoryConfigSchema.parse(config)).not.toThrow(); - }); - }); -}); - -describe('BuildConfigSchema', () => { - it('should accept empty build config', () => { - const config = {}; - expect(() => BuildConfigSchema.parse(config)).not.toThrow(); - }); - - it('should accept full build config', () => { - const config = { - buildCommand: 'npm run build', - outputDirectory: '.next', - installCommand: 'npm ci', - devCommand: 'npm run dev', - nodeVersion: '20.x', - env: { - NODE_ENV: 'production', - API_URL: 'https://api.example.com', - }, - }; - - expect(() => BuildConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('DeploymentConfigSchema', () => { - it('should accept minimal deployment config', () => { - const config = {}; - - const result = DeploymentConfigSchema.parse(config); - expect(result.autoDeployment).toBe(true); - expect(result.enablePreview).toBe(true); - expect(result.previewComments).toBe(true); - }); - - it('should accept deployment with regions', () => { - const config = { - regions: ['iad1', 'sfo1', 'fra1'], - }; - - expect(() => DeploymentConfigSchema.parse(config)).not.toThrow(); - }); - - it('should accept deployment with deploy hooks', () => { - const config = { - deployHooks: [ - { - name: 'main-deploy', - url: 'https://api.vercel.com/v1/integrations/deploy/xxx', - branch: 'main', - }, - ], - }; - - expect(() => DeploymentConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('DomainConfigSchema', () => { - it('should accept minimal domain config', () => { - const config = { - domain: 'app.example.com', - }; - - const result = DomainConfigSchema.parse(config); - expect(result.httpsRedirect).toBe(true); - }); - - it('should accept domain with custom SSL', () => { - const config = { - domain: 'secure.example.com', - customCertificate: { - cert: '-----BEGIN CERTIFICATE-----', - key: '-----BEGIN PRIVATE KEY-----', - ca: '-----BEGIN CERTIFICATE-----', - }, - }; - - expect(() => DomainConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('EnvironmentVariablesSchema', () => { - it('should accept environment variable', () => { - const envVar = { - key: 'API_KEY', - value: 'secret-value', - target: ['production'] as const, - }; - - const result = EnvironmentVariablesSchema.parse(envVar); - expect(result.isSecret).toBe(false); - }); - - it('should accept secret environment variable', () => { - const envVar = { - key: 'DATABASE_URL', - value: 'postgresql://...', - target: ['production', 'preview'] as const, - isSecret: true, - }; - - expect(() => EnvironmentVariablesSchema.parse(envVar)).not.toThrow(); - }); - - it('should accept all target environments', () => { - const targets = ['production', 'preview', 'development'] as const; - - targets.forEach(target => { - const envVar = { - key: 'TEST', - value: 'value', - target: [target], - }; - expect(() => EnvironmentVariablesSchema.parse(envVar)).not.toThrow(); - }); - }); -}); - -describe('EdgeFunctionConfigSchema', () => { - it('should accept minimal edge function', () => { - const func = { - name: 'api-handler', - path: '/api/*', - }; - - const result = EdgeFunctionConfigSchema.parse(func); - expect(result.memoryLimit).toBe(1024); - expect(result.timeout).toBe(10); - }); - - it('should accept edge function with custom limits', () => { - const func = { - name: 'heavy-processor', - path: '/api/process', - regions: ['iad1', 'sfo1'], - memoryLimit: 3008, - timeout: 60, - }; - - expect(() => EdgeFunctionConfigSchema.parse(func)).not.toThrow(); - }); - - it('should enforce memory limits', () => { - expect(() => EdgeFunctionConfigSchema.parse({ - name: 'test', - path: '/test', - memoryLimit: 100, // Too low - })).toThrow(); - - expect(() => EdgeFunctionConfigSchema.parse({ - name: 'test', - path: '/test', - memoryLimit: 5000, // Too high - })).toThrow(); - }); - - it('should enforce timeout limits', () => { - expect(() => EdgeFunctionConfigSchema.parse({ - name: 'test', - path: '/test', - timeout: 0, // Too low - })).toThrow(); - - expect(() => EdgeFunctionConfigSchema.parse({ - name: 'test', - path: '/test', - timeout: 400, // Too high - })).toThrow(); - }); -}); - -describe('VercelProjectSchema', () => { - it('should accept minimal project', () => { - const project = { - name: 'my-app', - }; - - expect(() => VercelProjectSchema.parse(project)).not.toThrow(); - }); - - it('should accept all framework types', () => { - const frameworks = ['nextjs', 'react', 'vue', 'nuxtjs', 'gatsby', 'remix', 'astro', 'sveltekit', 'solid', 'angular', 'static', 'other'] as const; - - frameworks.forEach(framework => { - const project = { - name: 'test-app', - framework, - }; - expect(() => VercelProjectSchema.parse(project)).not.toThrow(); - }); - }); - - it('should accept full project configuration', () => { - const project = { - name: 'full-app', - framework: 'nextjs' as const, - gitRepository: { - type: 'github' as const, - repo: 'owner/repo', - productionBranch: 'main', - }, - buildConfig: { - buildCommand: 'npm run build', - outputDirectory: '.next', - }, - deploymentConfig: { - regions: ['iad1', 'sfo1'], - enablePreview: true, - }, - domains: [ - { domain: 'app.example.com' }, - ], - environmentVariables: [ - { - key: 'API_KEY', - value: 'test', - target: ['production'] as const, - }, - ], - edgeFunctions: [ - { - name: 'api', - path: '/api/*', - }, - ], - rootDirectory: 'apps/web', - }; - - expect(() => VercelProjectSchema.parse(project)).not.toThrow(); - }); -}); - -describe('VercelConnectorSchema', () => { - describe('Basic Properties', () => { - it('should accept minimal Vercel connector', () => { - const connector: VercelConnector = { - name: 'vercel_test', - label: 'Vercel Test', - type: 'saas', - provider: 'vercel', - authentication: { - type: 'bearer', - token: 'test-token', - }, - projects: [ - { - name: 'test-project', - }, - ], - }; - - const result = VercelConnectorSchema.parse(connector); - expect(result.baseUrl).toBe('https://api.vercel.com'); - expect(result.enableWebhooks).toBe(true); - }); - - it('should enforce snake_case for connector name', () => { - const validNames = ['vercel_test', 'vercel_production', '_internal']; - validNames.forEach(name => { - expect(() => VercelConnectorSchema.parse({ - name, - label: 'Test', - type: 'saas', - provider: 'vercel', - authentication: { type: 'bearer', token: 'x' }, - projects: [{ name: 'test' }], - })).not.toThrow(); - }); - - const invalidNames = ['vercelTest', 'Vercel-Test', '123vercel']; - invalidNames.forEach(name => { - expect(() => VercelConnectorSchema.parse({ - name, - label: 'Test', - type: 'saas', - provider: 'vercel', - authentication: { type: 'bearer', token: 'x' }, - projects: [{ name: 'test' }], - })).toThrow(); - }); - }); - }); - - describe('Team Configuration', () => { - it('should accept team configuration', () => { - const connector: VercelConnector = { - name: 'vercel_team', - label: 'Vercel Team', - type: 'saas', - provider: 'vercel', - authentication: { - type: 'bearer', - token: 'test-token', - }, - team: { - teamId: 'team_xxx', - teamName: 'My Team', - }, - projects: [ - { - name: 'team-project', - }, - ], - }; - - expect(() => VercelConnectorSchema.parse(connector)).not.toThrow(); - }); - }); - - describe('Monitoring Configuration', () => { - it('should accept monitoring configuration', () => { - const connector: VercelConnector = { - name: 'vercel_monitored', - label: 'Vercel Monitored', - type: 'saas', - provider: 'vercel', - authentication: { - type: 'bearer', - token: 'test-token', - }, - projects: [{ name: 'test' }], - monitoring: { - enableWebAnalytics: true, - enableSpeedInsights: true, - logDrains: [ - { - name: 'datadog', - url: 'https://logs.datadoghq.com', - headers: { 'DD-API-KEY': 'xxx' }, - sources: ['lambda', 'edge'], - }, - ], - }, - }; - - expect(() => VercelConnectorSchema.parse(connector)).not.toThrow(); - }); - }); - - describe('Webhook Configuration', () => { - it('should accept webhook events', () => { - const events = [ - 'deployment.created', - 'deployment.succeeded', - 'deployment.failed', - 'deployment.ready', - 'deployment.error', - 'deployment.canceled', - 'deployment-checks-completed', - 'deployment-prepared', - 'project.created', - 'project.removed', - ] as const; - - const connector: VercelConnector = { - name: 'vercel_webhooks', - label: 'Vercel Webhooks', - type: 'saas', - provider: 'vercel', - authentication: { type: 'bearer', token: 'x' }, - projects: [{ name: 'test' }], - enableWebhooks: true, - webhookEvents: [...events], - }; - - expect(() => VercelConnectorSchema.parse(connector)).not.toThrow(); - }); - }); - - describe('Example Configurations', () => { - it('should accept Next.js connector example', () => { - expect(() => VercelConnectorSchema.parse(vercelNextJsConnectorExample)).not.toThrow(); - }); - - it('should accept static site connector example', () => { - expect(() => VercelConnectorSchema.parse(vercelStaticSiteConnectorExample)).not.toThrow(); - }); - }); -}); diff --git a/packages/spec/src/integration/connector/vercel.zod.ts b/packages/spec/src/integration/connector/vercel.zod.ts deleted file mode 100644 index 7b0c80133b..0000000000 --- a/packages/spec/src/integration/connector/vercel.zod.ts +++ /dev/null @@ -1,645 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { - ConnectorSchema, -} from '../connector.zod'; - -/** - * Vercel Connector Protocol - * - * Specialized connector for Vercel deployment platform enabling automated - * deployments, preview environments, and production releases. - * - * Use Cases: - * - Automated deployments from Git - * - Preview deployments for pull requests - * - Production releases - * - Environment variable management - * - Domain and SSL configuration - * - Edge function deployment - * - * @example - * ```typescript - * import { VercelConnector } from '@objectstack/spec/integration'; - * - * const vercelConnector: VercelConnector = { - * name: 'vercel_production', - * label: 'Vercel Production', - * type: 'saas', - * provider: 'vercel', - * baseUrl: 'https://api.vercel.com', - * authentication: { - * type: 'bearer', - * token: '${VERCEL_TOKEN}', - * }, - * projects: [ - * { - * name: 'objectstack-app', - * framework: 'nextjs', - * gitRepository: { - * type: 'github', - * repo: 'objectstack-ai/app', - * }, - * }, - * ], - * }; - * ``` - */ - -/** - * Vercel Provider Type - */ -import { lazySchema } from '../../shared/lazy-schema'; -export const VercelProviderSchema = lazySchema(() => z.enum([ - 'vercel', -]).describe('Vercel provider type')); - -export type VercelProvider = z.infer; - -/** - * Vercel Framework Types - */ -export const VercelFrameworkSchema = lazySchema(() => z.enum([ - 'nextjs', - 'react', - 'vue', - 'nuxtjs', - 'gatsby', - 'remix', - 'astro', - 'sveltekit', - 'solid', - 'angular', - 'static', - 'other', -]).describe('Frontend framework')); - -export type VercelFramework = z.infer; - -/** - * Git Repository Configuration - */ -export const GitRepositoryConfigSchema = lazySchema(() => z.object({ - /** - * Git provider type - */ - type: z.enum(['github', 'gitlab', 'bitbucket']).describe('Git provider'), - - /** - * Repository identifier (owner/repo) - */ - repo: z.string().describe('Repository identifier (e.g., owner/repo)'), - - /** - * Production branch - */ - productionBranch: z.string().optional().default('main').describe('Production branch name'), - - /** - * Auto-deploy production branch - */ - autoDeployProduction: z.boolean().optional().default(true).describe('Auto-deploy production branch'), - - /** - * Auto-deploy preview branches - */ - autoDeployPreview: z.boolean().optional().default(true).describe('Auto-deploy preview branches'), -})); - -export type GitRepositoryConfig = z.infer; - -/** - * Build Configuration - */ -export const BuildConfigSchema = lazySchema(() => z.object({ - /** - * Build command - */ - buildCommand: z.string().optional().describe('Build command (e.g., npm run build)'), - - /** - * Output directory - */ - outputDirectory: z.string().optional().describe('Output directory (e.g., .next, dist)'), - - /** - * Install command - */ - installCommand: z.string().optional().describe('Install command (e.g., npm install, pnpm install)'), - - /** - * Development command - */ - devCommand: z.string().optional().describe('Development command (e.g., npm run dev)'), - - /** - * Node.js version - */ - nodeVersion: z.string().optional().describe('Node.js version (e.g., 18.x, 20.x)'), - - /** - * Environment variables - */ - env: z.record(z.string(), z.string()).optional().describe('Build environment variables'), -})); - -export type BuildConfig = z.infer; - -/** - * Deployment Configuration - */ -export const DeploymentConfigSchema = lazySchema(() => z.object({ - /** - * Enable automatic deployments - */ - autoDeployment: z.boolean().optional().default(true).describe('Enable automatic deployments'), - - /** - * Deployment regions - */ - regions: z.array(z.enum([ - 'iad1', // US East (Washington, D.C.) - 'sfo1', // US West (San Francisco) - 'gru1', // South America (São Paulo) - 'lhr1', // Europe West (London) - 'fra1', // Europe Central (Frankfurt) - 'sin1', // Asia (Singapore) - 'syd1', // Australia (Sydney) - 'hnd1', // Asia (Tokyo) - 'icn1', // Asia (Seoul) - ])).optional().describe('Deployment regions'), - - /** - * Enable preview deployments - */ - enablePreview: z.boolean().optional().default(true).describe('Enable preview deployments'), - - /** - * Preview deployment comments on PRs - */ - previewComments: z.boolean().optional().default(true).describe('Post preview URLs in PR comments'), - - /** - * Production deployment protection - */ - productionProtection: z.boolean().optional().default(true).describe('Require approval for production deployments'), - - /** - * Deploy hooks - */ - deployHooks: z.array(z.object({ - name: z.string().describe('Hook name'), - url: z.string().url().describe('Deploy hook URL'), - branch: z.string().optional().describe('Target branch'), - })).optional().describe('Deploy hooks'), -})); - -export type DeploymentConfig = z.infer; - -/** - * Domain Configuration - */ -export const DomainConfigSchema = lazySchema(() => z.object({ - /** - * Domain name - */ - domain: z.string().describe('Domain name (e.g., app.example.com)'), - - /** - * Enable HTTPS redirect - */ - httpsRedirect: z.boolean().optional().default(true).describe('Redirect HTTP to HTTPS'), - - /** - * Custom SSL certificate - */ - customCertificate: z.object({ - cert: z.string().describe('SSL certificate'), - key: z.string().describe('Private key'), - ca: z.string().optional().describe('Certificate authority'), - }).optional().describe('Custom SSL certificate'), - - /** - * Git branch for this domain - */ - gitBranch: z.string().optional().describe('Git branch to deploy to this domain'), -})); - -export type DomainConfig = z.infer; - -/** - * Environment Variables Configuration - */ -export const EnvironmentVariablesSchema = lazySchema(() => z.object({ - /** - * Variable name - */ - key: z.string().describe('Environment variable name'), - - /** - * Variable value - */ - value: z.string().describe('Environment variable value'), - - /** - * Target environments - */ - target: z.array(z.enum(['production', 'preview', 'development'])).describe('Target environments'), - - /** - * Is secret (encrypted) - */ - isSecret: z.boolean().optional().default(false).describe('Encrypt this variable'), - - /** - * Git branch (for preview/development) - */ - gitBranch: z.string().optional().describe('Specific git branch'), -})); - -export type EnvironmentVariables = z.infer; - -/** - * Edge Function Configuration - */ -export const EdgeFunctionConfigSchema = lazySchema(() => z.object({ - /** - * Function name - */ - name: z.string().describe('Edge function name'), - - /** - * Function path - */ - path: z.string().describe('Function path (e.g., /api/*)'), - - /** - * Regions to deploy - */ - regions: z.array(z.string()).optional().describe('Specific regions for this function'), - - /** - * Memory limit (MB) - */ - memoryLimit: z.number().int().min(128).max(3008).optional().default(1024).describe('Memory limit in MB'), - - /** - * Timeout (seconds) - */ - timeout: z.number().int().min(1).max(300).optional().default(10).describe('Timeout in seconds'), -})); - -export type EdgeFunctionConfig = z.infer; - -/** - * Vercel Project Configuration - */ -export const VercelProjectSchema = lazySchema(() => z.object({ - /** - * Project name - */ - name: z.string().describe('Vercel project name'), - - /** - * Framework - */ - framework: VercelFrameworkSchema.optional().describe('Frontend framework'), - - /** - * Git repository - */ - gitRepository: GitRepositoryConfigSchema.optional().describe('Git repository configuration'), - - /** - * Build configuration - */ - buildConfig: BuildConfigSchema.optional().describe('Build configuration'), - - /** - * Deployment configuration - */ - deploymentConfig: DeploymentConfigSchema.optional().describe('Deployment configuration'), - - /** - * Custom domains - */ - domains: z.array(DomainConfigSchema).optional().describe('Custom domains'), - - /** - * Environment variables - */ - environmentVariables: z.array(EnvironmentVariablesSchema).optional().describe('Environment variables'), - - /** - * Edge functions - */ - edgeFunctions: z.array(EdgeFunctionConfigSchema).optional().describe('Edge functions'), - - /** - * Root directory - */ - rootDirectory: z.string().optional().describe('Root directory (for monorepos)'), -})); - -export type VercelProject = z.infer; - -/** - * Vercel Monitoring Configuration - */ -export const VercelMonitoringSchema = lazySchema(() => z.object({ - /** - * Enable Web Analytics - */ - enableWebAnalytics: z.boolean().optional().default(false).describe('Enable Vercel Web Analytics'), - - /** - * Enable Speed Insights - */ - enableSpeedInsights: z.boolean().optional().default(false).describe('Enable Vercel Speed Insights'), - - /** - * Enable Log Drains - */ - logDrains: z.array(z.object({ - name: z.string().describe('Log drain name'), - url: z.string().url().describe('Log drain URL'), - headers: z.record(z.string(), z.string()).optional().describe('Custom headers'), - sources: z.array(z.enum(['static', 'lambda', 'edge'])).optional().describe('Log sources'), - })).optional().describe('Log drains configuration'), -})); - -export type VercelMonitoring = z.infer; - -/** - * Vercel Team Configuration - */ -export const VercelTeamSchema = lazySchema(() => z.object({ - /** - * Team ID or slug - */ - teamId: z.string().optional().describe('Team ID or slug'), - - /** - * Team name - */ - teamName: z.string().optional().describe('Team name'), -})); - -export type VercelTeam = z.infer; - -/** - * Vercel Connector Schema - * Complete Vercel integration configuration - */ -export const VercelConnectorSchema = lazySchema(() => ConnectorSchema.extend({ - type: z.literal('saas'), - - /** - * Vercel provider - */ - provider: VercelProviderSchema.describe('Vercel provider'), - - /** - * Vercel API base URL - */ - baseUrl: z.string().url().optional().default('https://api.vercel.com').describe('Vercel API base URL'), - - /** - * Team configuration - */ - team: VercelTeamSchema.optional().describe('Vercel team configuration'), - - /** - * Projects to manage - */ - projects: z.array(VercelProjectSchema).describe('Vercel projects'), - - /** - * Monitoring configuration - */ - monitoring: VercelMonitoringSchema.optional().describe('Monitoring configuration'), - - /** - * Enable webhooks - */ - enableWebhooks: z.boolean().optional().default(true).describe('Enable Vercel webhooks'), - - /** - * Webhook events to subscribe - */ - webhookEvents: z.array(z.enum([ - 'deployment.created', - 'deployment.succeeded', - 'deployment.failed', - 'deployment.ready', - 'deployment.error', - 'deployment.canceled', - 'deployment-checks-completed', - 'deployment-prepared', - 'project.created', - 'project.removed', - ])).optional().describe('Webhook events to subscribe to'), -})); - -export type VercelConnector = z.infer; - -// ============================================================================ -// Helper Functions & Examples -// ============================================================================ - -/** - * Example: Vercel Next.js Project Configuration - */ -export const vercelNextJsConnectorExample = { - name: 'vercel_production', - label: 'Vercel Production', - type: 'saas', - provider: 'vercel', - baseUrl: 'https://api.vercel.com', - - authentication: { - type: 'bearer', - token: '${VERCEL_TOKEN}', - }, - - projects: [ - { - name: 'objectstack-app', - framework: 'nextjs', - - gitRepository: { - type: 'github', - repo: 'objectstack-ai/app', - productionBranch: 'main', - autoDeployProduction: true, - autoDeployPreview: true, - }, - - buildConfig: { - buildCommand: 'npm run build', - outputDirectory: '.next', - installCommand: 'npm ci', - devCommand: 'npm run dev', - nodeVersion: '20.x', - env: { - NEXT_PUBLIC_API_URL: 'https://api.objectstack.ai', - }, - }, - - deploymentConfig: { - autoDeployment: true, - regions: ['iad1', 'sfo1', 'fra1'], - enablePreview: true, - previewComments: true, - productionProtection: true, - }, - - domains: [ - { - domain: 'app.objectstack.ai', - httpsRedirect: true, - gitBranch: 'main', - }, - { - domain: 'staging.objectstack.ai', - httpsRedirect: true, - gitBranch: 'develop', - }, - ], - - environmentVariables: [ - { - key: 'DATABASE_URL', - value: '${DATABASE_URL}', - target: ['production', 'preview'], - isSecret: true, - }, - { - key: 'NEXT_PUBLIC_ANALYTICS_ID', - value: 'UA-XXXXXXXX-X', - target: ['production'], - isSecret: false, - }, - ], - - edgeFunctions: [ - { - name: 'api-middleware', - path: '/api/*', - regions: ['iad1', 'sfo1'], - memoryLimit: 1024, - timeout: 10, - }, - ], - }, - ], - - monitoring: { - enableWebAnalytics: true, - enableSpeedInsights: true, - logDrains: [ - { - name: 'datadog-logs', - url: 'https://http-intake.logs.datadoghq.com/api/v2/logs', - headers: { - 'DD-API-KEY': '${DATADOG_API_KEY}', - }, - sources: ['lambda', 'edge'], - }, - ], - }, - - enableWebhooks: true, - webhookEvents: [ - 'deployment.succeeded', - 'deployment.failed', - 'deployment.ready', - ], - - status: 'active', - enabled: true, -}; - -/** - * Example: Vercel Static Site Configuration - */ -export const vercelStaticSiteConnectorExample = { - name: 'vercel_docs', - label: 'Vercel Documentation', - type: 'saas', - provider: 'vercel', - baseUrl: 'https://api.vercel.com', - - authentication: { - type: 'bearer', - token: '${VERCEL_TOKEN}', - }, - - team: { - teamId: 'team_xxxxxx', - teamName: 'ObjectStack', - }, - - projects: [ - { - name: 'objectstack-docs', - framework: 'static', - - gitRepository: { - type: 'github', - repo: 'objectstack-ai/docs', - productionBranch: 'main', - autoDeployProduction: true, - autoDeployPreview: true, - }, - - buildConfig: { - buildCommand: 'npm run build', - outputDirectory: 'dist', - installCommand: 'npm ci', - nodeVersion: '18.x', - }, - - deploymentConfig: { - autoDeployment: true, - regions: ['iad1', 'lhr1', 'sin1'], - enablePreview: true, - previewComments: true, - productionProtection: false, - }, - - domains: [ - { - domain: 'docs.objectstack.ai', - httpsRedirect: true, - }, - ], - - environmentVariables: [ - { - key: 'ALGOLIA_APP_ID', - value: '${ALGOLIA_APP_ID}', - target: ['production', 'preview'], - isSecret: false, - }, - { - key: 'ALGOLIA_API_KEY', - value: '${ALGOLIA_API_KEY}', - target: ['production', 'preview'], - isSecret: true, - }, - ], - }, - ], - - monitoring: { - enableWebAnalytics: true, - enableSpeedInsights: false, - }, - - enableWebhooks: false, - - status: 'active', - enabled: true, -}; diff --git a/packages/spec/src/integration/index.ts b/packages/spec/src/integration/index.ts index e6d75ceaf3..8dc614a76f 100644 --- a/packages/spec/src/integration/index.ts +++ b/packages/spec/src/integration/index.ts @@ -2,14 +2,25 @@ /** * Integration Protocol Exports - * - * External System Connection Protocols - * - Connector configurations for SaaS, databases, file storage, message queues - * - GitHub integration (version control, CI/CD) - * - Vercel integration (deployment, hosting) + * + * External System Connection Protocols (ADR-0097) + * - The connector protocol: one `ConnectorSchema`, provider-bound declarative + * instances, and the registry descriptor `GET /automation/connectors` serves * - Authentication methods (OAuth2, API Key, JWT, SAML) * - Data synchronization and field mapping * - Webhooks, rate limiting, and retry strategies + * + * The per-provider "Connector Templates" (`connector/saas.zod.ts`, + * `connector/database.zod.ts`, file-storage, message-queue, github, vercel) + * were removed in #4480. They were the losing side of an architecture decision + * this module's live half already records: ADR-0023 rejected hand-modelling + * each external system's shape inside the spec, and ADR-0097's answer is the + * opposite direction — provider shapes come from the provider itself + * (connector-openapi materializes from an OpenAPI document, connector-mcp from + * an MCP server), while the platform defines only the unified protocol. The + * six files had zero consumers: nothing in this module referenced them, no + * runtime read them, and `engine.registerConnector()` validates against + * `ConnectorSchema` from `./connector.zod` alone. */ // Core Connector Protocol @@ -22,11 +33,3 @@ export * from './connector-provider-errors'; // Connector registry vocabulary — origin/state and the descriptor // `GET /automation/connectors` serves (ADR-0022, ADR-0097 §4, #3017) export * from './connector-descriptor'; - -// Connector Templates -export * from './connector/saas.zod'; -export * from './connector/database.zod'; -export * from './connector/file-storage.zod'; -export * from './connector/message-queue.zod'; -export * from './connector/github.zod'; -export * from './connector/vercel.zod'; diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index de53150651..9a5981b686 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -31,7 +31,13 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // #4148 covered object+field: 2 surfaces. The point of this walker is the // rest. If the derivation regresses to a handful, the "evidence base" for // the #4001 strict tiers quietly becomes a sample again. - expect(lintables.length).toBeGreaterThanOrEqual(14); + // + // This floor RATCHETS DOWN as #4001 advances — every graduation moves a type + // from "lint warns" to "parse rejects", which is the campaign succeeding, not + // coverage rotting. Lower it only after confirming the shrink against the + // list below; that confirmation is the whole point of pinning a number here. + // 15 → 13 when `seed` + `doc` graduated (#4001 registered-types batch). + expect(lintables.length).toBeGreaterThanOrEqual(13); // `view` matters doubly: it is a UNION (container | ViewItem | overlay), so // its presence pins the union half of the posture logic — a regression that // silently dropped unions would shrink coverage without failing the count. @@ -50,7 +56,12 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // count fell 16 → 14 and the pin above failed until both were confirmed // graduations and moved into this list. `hook` also had to leave the // pinned-coverage list above, where it had been an expected lint target. - for (const strict of ['flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource']) { + // It did it a third time for `seed` + `doc`, the first two conversions built + // on `strictObject` — which also proved the posture derivation reads a + // helper-built `.strict()` exactly like a hand-wired one. + for (const strict of [ + 'flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource', 'seed', 'doc', + ]) { expect(lintableTypes, `'${strict}' is .strict(); the lint must not double-report`).not.toContain(strict); } }); diff --git a/packages/spec/src/kernel/metadata-loader.test.ts b/packages/spec/src/kernel/metadata-loader.test.ts index f885667fe8..c94e10c48a 100644 --- a/packages/spec/src/kernel/metadata-loader.test.ts +++ b/packages/spec/src/kernel/metadata-loader.test.ts @@ -1,403 +1,25 @@ import { describe, it, expect } from 'vitest'; import { - MetadataFormatSchema, - MetadataStatsSchema, - MetadataLoadOptionsSchema, - MetadataSaveOptionsSchema, - MetadataExportOptionsSchema, - MetadataImportOptionsSchema, - MetadataLoadResultSchema, - MetadataSaveResultSchema, - MetadataWatchEventSchema, - MetadataCollectionInfoSchema, - MetadataLoaderContractSchema, + MetadataFallbackStrategySchema, MetadataManagerConfigSchema, } from './metadata-loader.zod'; -describe('MetadataLoaderProtocol', () => { - describe('MetadataFormatSchema', () => { - it('should accept valid formats', () => { - expect(MetadataFormatSchema.parse('json')).toBe('json'); - expect(MetadataFormatSchema.parse('yaml')).toBe('yaml'); - expect(MetadataFormatSchema.parse('typescript')).toBe('typescript'); - expect(MetadataFormatSchema.parse('javascript')).toBe('javascript'); +// The loader/persistence envelope vocabulary this file used to also cover +// (`MetadataFormat`, `MetadataStats`, `MetadataLoad*`, `MetadataSave*`, +// `MetadataExport/ImportOptions`, `MetadataWatchEvent`, +// `MetadataCollectionInfo`, `MetadataLoaderContract`) was a zero-consumer +// duplicate of `system/metadata-persistence.zod`, removed in #4411. Its tests +// live with the surviving source: `../system/metadata-persistence.test.ts`. +describe('MetadataManagerConfig', () => { + describe('MetadataFallbackStrategySchema', () => { + it('should accept every fallback strategy', () => { + for (const strategy of ['filesystem', 'memory', 'none'] as const) { + expect(MetadataFallbackStrategySchema.parse(strategy)).toBe(strategy); + } }); - it('should reject invalid formats', () => { - expect(() => MetadataFormatSchema.parse('xml')).toThrow(); - expect(() => MetadataFormatSchema.parse('toml')).toThrow(); - }); - }); - - describe('MetadataStatsSchema', () => { - it('should validate metadata statistics', () => { - const stats = { - size: 1024, - modifiedAt: '2026-01-31T00:00:00.000Z', - etag: '"abc123"', - format: 'json' as const, - }; - - const result = MetadataStatsSchema.parse(stats); - expect(result.size).toBe(1024); - expect(result.etag).toBe('"abc123"'); - expect(result.format).toBe('json'); - }); - - it('should allow optional fields', () => { - const stats = { - size: 2048, - modifiedAt: new Date().toISOString(), - etag: '"xyz789"', - format: 'yaml' as const, - path: '/metadata/objects/customer.object.yaml', - metadata: { encoding: 'utf-8' }, - }; - - const result = MetadataStatsSchema.parse(stats); - expect(result.path).toBe('/metadata/objects/customer.object.yaml'); - expect(result.metadata).toEqual({ encoding: 'utf-8' }); - }); - - it('should reject negative size', () => { - const stats = { - size: -100, - modifiedAt: new Date().toISOString(), - etag: '"abc"', - format: 'json' as const, - }; - - expect(() => MetadataStatsSchema.parse(stats)).toThrow(); - }); - }); - - describe('MetadataLoadOptionsSchema', () => { - it('should apply default values', () => { - const options = {}; - const result = MetadataLoadOptionsSchema.parse(options); - - expect(result.validate).toBe(true); - expect(result.useCache).toBe(true); - expect(result.recursive).toBe(true); - }); - - it('should accept all options', () => { - const options = { - patterns: ['**/*.object.ts', '**/*.object.json'], - ifNoneMatch: '"etag123"', - ifModifiedSince: '2026-01-01T00:00:00.000Z', - validate: false, - useCache: false, - filter: '(item) => item.name.startsWith("sys_")', - limit: 100, - recursive: false, - }; - - const result = MetadataLoadOptionsSchema.parse(options); - expect(result.patterns).toHaveLength(2); - expect(result.limit).toBe(100); - expect(result.validate).toBe(false); - }); - }); - - describe('MetadataSaveOptionsSchema', () => { - it('should apply default values', () => { - const options = {}; - const result = MetadataSaveOptionsSchema.parse(options); - - expect(result.format).toBe('typescript'); - expect(result.prettify).toBe(true); - expect(result.indent).toBe(2); - expect(result.overwrite).toBe(true); - expect(result.atomic).toBe(true); - }); - - it('should validate indent range', () => { - expect(() => - MetadataSaveOptionsSchema.parse({ indent: -1 }) - ).toThrow(); - - expect(() => - MetadataSaveOptionsSchema.parse({ indent: 10 }) - ).toThrow(); - - expect( - MetadataSaveOptionsSchema.parse({ indent: 4 }).indent - ).toBe(4); - }); - - it('should accept custom path', () => { - const options = { - path: '/custom/path/object.ts', - format: 'json' as const, - }; - - const result = MetadataSaveOptionsSchema.parse(options); - expect(result.path).toBe('/custom/path/object.ts'); - expect(result.format).toBe('json'); - }); - }); - - describe('MetadataExportOptionsSchema', () => { - it('should require output path', () => { - expect(() => MetadataExportOptionsSchema.parse({})).toThrow(); - - const options = { output: './export/objects.json' }; - const result = MetadataExportOptionsSchema.parse(options); - expect(result.output).toBe('./export/objects.json'); - }); - - it('should apply defaults', () => { - const options = { output: './export.json' }; - const result = MetadataExportOptionsSchema.parse(options); - - expect(result.format).toBe('json'); - expect(result.includeStats).toBe(false); - expect(result.compress).toBe(false); - expect(result.prettify).toBe(true); - }); - }); - - describe('MetadataImportOptionsSchema', () => { - it('should apply default conflict resolution', () => { - const options = {}; - const result = MetadataImportOptionsSchema.parse(options); - - expect(result.conflictResolution).toBe('merge'); - expect(result.validate).toBe(true); - expect(result.dryRun).toBe(false); - expect(result.continueOnError).toBe(false); - }); - - it('should accept all conflict strategies', () => { - const strategies = ['skip', 'overwrite', 'merge', 'fail'] as const; - - strategies.forEach(strategy => { - const result = MetadataImportOptionsSchema.parse({ - conflictResolution: strategy - }); - expect(result.conflictResolution).toBe(strategy); - }); - }); - - it('should accept transform function', () => { - const options = { - transform: '(item) => ({ ...item, imported: true })', - }; - - const result = MetadataImportOptionsSchema.parse(options); - expect(result.transform).toBeDefined(); - }); - }); - - describe('MetadataLoadResultSchema', () => { - it('should validate load result', () => { - const result = { - data: { name: 'customer', label: 'Customer' }, - fromCache: false, - notModified: false, - }; - - const validated = MetadataLoadResultSchema.parse(result); - expect(validated.data).toBeDefined(); - expect(validated.fromCache).toBe(false); - }); - - it('should accept null data (not found)', () => { - const result = { - data: null, - fromCache: false, - notModified: false, - }; - - const validated = MetadataLoadResultSchema.parse(result); - expect(validated.data).toBeNull(); - }); - - it('should include optional fields', () => { - const result = { - data: { name: 'test' }, - fromCache: true, - notModified: true, - etag: '"abc123"', - stats: { - size: 512, - modifiedAt: new Date().toISOString(), - etag: '"abc123"', - format: 'typescript' as const, - }, - loadTime: 45.5, - }; - - const validated = MetadataLoadResultSchema.parse(result); - expect(validated.etag).toBe('"abc123"'); - expect(validated.loadTime).toBe(45.5); - expect(validated.stats).toBeDefined(); - }); - }); - - describe('MetadataSaveResultSchema', () => { - it('should validate save result', () => { - const result = { - success: true, - path: '/metadata/objects/customer.object.ts', - }; - - const validated = MetadataSaveResultSchema.parse(result); - expect(validated.success).toBe(true); - expect(validated.path).toBeDefined(); - }); - - it('should include optional fields', () => { - const result = { - success: true, - path: '/metadata/objects/customer.object.ts', - etag: '"new-etag"', - size: 2048, - saveTime: 12.3, - backupPath: '/metadata/objects/customer.object.ts.bak', - }; - - const validated = MetadataSaveResultSchema.parse(result); - expect(validated.size).toBe(2048); - expect(validated.backupPath).toBeDefined(); - }); - }); - - describe('MetadataWatchEventSchema', () => { - it('should validate watch events', () => { - const events = [ - { - type: 'added' as const, - metadataType: 'object', - name: 'customer', - path: '/objects/customer.object.ts', - data: { name: 'customer' }, - timestamp: new Date().toISOString(), - }, - { - type: 'changed' as const, - metadataType: 'view', - name: 'customer_list', - path: '/views/customer_list.view.ts', - timestamp: new Date().toISOString(), - }, - { - type: 'deleted' as const, - metadataType: 'app', - name: 'old_app', - path: '/apps/old_app.ts', - timestamp: new Date().toISOString(), - }, - ]; - - events.forEach(event => { - const validated = MetadataWatchEventSchema.parse(event); - expect(validated.type).toBe(event.type); - expect(validated.metadataType).toBeDefined(); - }); - }); - }); - - describe('MetadataCollectionInfoSchema', () => { - it('should validate collection info', () => { - const info = { - type: 'object', - count: 42, - formats: ['typescript', 'json'] as const, - }; - - const validated = MetadataCollectionInfoSchema.parse(info); - expect(validated.count).toBe(42); - expect(validated.formats).toHaveLength(2); - }); - - it('should accept optional fields', () => { - const info = { - type: 'view', - count: 15, - formats: ['yaml'] as const, - totalSize: 51200, - lastModified: '2026-01-31T00:00:00.000Z', - location: '/metadata/views', - }; - - const validated = MetadataCollectionInfoSchema.parse(info); - expect(validated.totalSize).toBe(51200); - expect(validated.location).toBe('/metadata/views'); - }); - }); - - describe('MetadataLoaderContractSchema', () => { - it('should validate loader contract', () => { - const contract = { - name: 'filesystem', - protocol: 'file:', - capabilities: { - read: true, - write: true, - watch: false, - list: true, - }, - supportedFormats: ['json', 'yaml', 'typescript'] as const, - }; - - const validated = MetadataLoaderContractSchema.parse(contract); - expect(validated.name).toBe('filesystem'); - expect(validated.protocol).toBe('file:'); - expect(validated.supportsWatch).toBe(false); // default - expect(validated.supportsWrite).toBe(true); // default - expect(validated.supportsCache).toBe(true); // default - }); - - it('should allow custom capabilities', () => { - const contract = { - name: 'http', - protocol: 'http:', - capabilities: { - read: true, - write: false, - watch: false, - list: false, - }, - supportedFormats: ['json'] as const, - supportsWatch: false, - supportsWrite: false, - supportsCache: true, - }; - - const validated = MetadataLoaderContractSchema.parse(contract); - expect(validated.protocol).toBe('http:'); - expect(validated.supportsWrite).toBe(false); - expect(validated.supportsCache).toBe(true); - }); - - it('should accept datasource protocol', () => { - const contract = { - name: 'database', - protocol: 'datasource:', - capabilities: { read: true, write: true, watch: false, list: true }, - supportedFormats: ['json'] as const, - }; - - const validated = MetadataLoaderContractSchema.parse(contract); - expect(validated.protocol).toBe('datasource:'); - expect(validated.capabilities.write).toBe(true); - }); - - it('should accept all valid protocols', () => { - const protocols = ['file:', 'http:', 's3:', 'datasource:', 'memory:']; - protocols.forEach((protocol) => { - expect(() => MetadataLoaderContractSchema.parse({ - name: 'test', protocol, capabilities: {}, supportedFormats: ['json'], - })).not.toThrow(); - }); - }); - - it('should reject invalid protocol', () => { - expect(() => MetadataLoaderContractSchema.parse({ - name: 'test', protocol: 'ftp:', capabilities: {}, supportedFormats: ['json'], - })).toThrow(); + it('should reject an unknown strategy', () => { + expect(() => MetadataFallbackStrategySchema.parse('redis')).toThrow(); }); }); @@ -405,7 +27,7 @@ describe('MetadataLoaderProtocol', () => { it('should apply defaults', () => { const config = {}; const validated = MetadataManagerConfigSchema.parse(config); - + expect(validated.formats).toEqual(['typescript', 'json', 'yaml']); expect(validated.watch).toBe(false); expect(validated.tableName).toBe('sys_metadata'); @@ -452,7 +74,7 @@ describe('MetadataLoaderProtocol', () => { encoding: 'utf-8', }, }; - + const validated = MetadataManagerConfigSchema.parse(config); expect(validated.datasource).toBe('postgres_main'); expect(validated.rootDir).toBe('/metadata'); @@ -477,7 +99,7 @@ describe('MetadataLoaderProtocol', () => { const config = { cache: { enabled: true, ttl: -100 }, }; - + expect(() => MetadataManagerConfigSchema.parse(config)).toThrow(); }); }); diff --git a/packages/spec/src/kernel/metadata-loader.zod.ts b/packages/spec/src/kernel/metadata-loader.zod.ts index 0e3e6cebe3..fb02e526e0 100644 --- a/packages/spec/src/kernel/metadata-loader.zod.ts +++ b/packages/spec/src/kernel/metadata-loader.zod.ts @@ -3,411 +3,32 @@ import { z } from 'zod'; /** - * # Metadata Loader Protocol - * - * Defines the standard interface for loading and saving metadata in ObjectStack. - * This protocol enables consistent metadata operations across different storage backends - * (filesystem, HTTP, S3, databases) and serialization formats (JSON, YAML, TypeScript). + * # Metadata Manager Configuration + * + * How the runtime `MetadataManager` is wired: which datasource backs `sys_metadata`, what to fall back to when that datasource is unreachable, cache / watch / validation settings, and the persistence write gates. + * + * The loader and watch *envelope* types (`MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataWatchEvent`, `MetadataLoaderContract`, …) are NOT here — they live in `@objectstack/spec/system` (`system/metadata-persistence.zod`), which is their single source. */ -/** - * Metadata Format Enum - * Supported serialization formats for metadata - */ -import { lazySchema } from '../shared/lazy-schema'; -import { ExpressionInputSchema } from '../shared/expression.zod'; -export const MetadataFormatSchema = lazySchema(() => z.enum(['json', 'yaml', 'typescript', 'javascript'])); - -/** - * Metadata Statistics - * Information about a metadata item without loading its full content - */ -export const MetadataStatsSchema = lazySchema(() => z.object({ - /** - * Size of the metadata file in bytes - */ - size: z.number().int().min(0).describe('File size in bytes'), - - /** - * Last modification timestamp - */ - modifiedAt: z.string().datetime().describe('Last modified date'), - - /** - * ETag for cache validation - * Used for conditional requests (If-None-Match header) - */ - etag: z.string().describe('Entity tag for cache validation'), - - /** - * Serialization format - */ - format: MetadataFormatSchema.describe('Serialization format'), - - /** - * Full file path (if applicable) - */ - path: z.string().optional().describe('File system path'), - - /** - * Additional metadata provider-specific properties - */ - metadata: z.record(z.string(), z.unknown()).optional().describe('Provider-specific metadata'), -})); - -/** - * Metadata Load Options - */ -export const MetadataLoadOptionsSchema = lazySchema(() => z.object({ - /** - * Glob patterns to match files - * Example: ["**\/*.object.ts", "**\/*.object.json"] - */ - patterns: z.array(z.string()).optional().describe('File glob patterns'), - - /** - * If-None-Match header for conditional loading - * Only load if ETag doesn't match - */ - ifNoneMatch: z.string().optional().describe('ETag for conditional request'), - - /** - * If-Modified-Since header for conditional loading - */ - ifModifiedSince: z.string().datetime().optional().describe('Only load if modified after this date'), - - /** - * Whether to validate against Zod schema - */ - validate: z.boolean().default(true).describe('Validate against schema'), - - /** - * Whether to use cache if available - */ - useCache: z.boolean().default(true).describe('Enable caching'), - - /** - * Filter predicate — CEL expression evaluated against each metadata item. - * Example: P`item.name.startsWith('sys_')` - */ - filter: ExpressionInputSchema.optional().describe('Filter predicate (CEL)'), - - /** - * Maximum number of items to load - */ - limit: z.number().int().min(1).optional().describe('Maximum items to load'), - - /** - * Recursively search subdirectories - */ - recursive: z.boolean().default(true).describe('Search subdirectories'), -})); - -/** - * Metadata Save Options - */ -export const MetadataSaveOptionsSchema = lazySchema(() => z.object({ - /** - * Serialization format - */ - format: MetadataFormatSchema.default('typescript').describe('Output format'), - - /** - * Prettify output (formatted with indentation) - */ - prettify: z.boolean().default(true).describe('Format with indentation'), - - /** - * Indentation size (spaces) - */ - indent: z.number().int().min(0).max(8).default(2).describe('Indentation spaces'), - - /** - * Sort object keys alphabetically - */ - sortKeys: z.boolean().default(false).describe('Sort object keys'), - - /** - * Include default values in output - */ - includeDefaults: z.boolean().default(false).describe('Include default values'), - - /** - * Create backup before overwriting - */ - backup: z.boolean().default(false).describe('Create backup file'), - - /** - * Overwrite if exists - */ - overwrite: z.boolean().default(true).describe('Overwrite existing file'), - - /** - * Atomic write (write to temp file, then rename) - */ - atomic: z.boolean().default(true).describe('Use atomic write operation'), - - /** - * Custom file path (overrides default location) - */ - path: z.string().optional().describe('Custom output path'), -})); - -/** - * Metadata Export Options - */ -export const MetadataExportOptionsSchema = lazySchema(() => z.object({ - /** - * Output file path - */ - output: z.string().describe('Output file path'), - - /** - * Export format - */ - format: MetadataFormatSchema.default('json').describe('Export format'), - - /** - * Filter predicate — CEL expression evaluated against each metadata item. - */ - filter: ExpressionInputSchema.optional().describe('Filter items to export (CEL)'), - - /** - * Include statistics in export - */ - includeStats: z.boolean().default(false).describe('Include metadata statistics'), - - /** - * Compress output - */ - compress: z.boolean().default(false).describe('Compress output (gzip)'), - - /** - * Pretty print output - */ - prettify: z.boolean().default(true).describe('Pretty print output'), -})); - -/** - * Metadata Import Options - */ -export const MetadataImportOptionsSchema = lazySchema(() => z.object({ - /** - * Conflict resolution strategy - */ - conflictResolution: z.enum(['skip', 'overwrite', 'merge', 'fail']) - .default('merge') - .describe('How to handle existing items'), - - /** - * Validate items against schema - */ - validate: z.boolean().default(true).describe('Validate before import'), - - /** - * Dry run (don't actually save) - */ - dryRun: z.boolean().default(false).describe('Simulate import without saving'), - - /** - * Continue on errors - */ - continueOnError: z.boolean().default(false).describe('Continue if validation fails'), - - /** - * Transform function (as string) - * Example: "(item) => ({ ...item, imported: true })" - */ - transform: z.string().optional().describe('Transform items before import'), -})); +// Until #4411 this file ALSO declared its own copy of all eleven of those +// envelope types. Each name existed twice across two subpath entries +// (`@objectstack/spec/kernel` and `@objectstack/spec/system`) with a different +// shape, so which one you got depended on your import path — a coin-flip an +// auto-import or a model completion has no way to win on purpose, and the +// stricter-looking, more heavily documented copy was the DEAD one. Every +// consumer in this repo, `cloud` and `objectui` imported the `system` copy; +// the kernel copies had zero runtime consumers and only their own test +// parsing them, so they were removed under ADR-0049 enforce-or-remove. +// Manager *wiring* stays here; the *envelope* is owned by `system`. -/** - * Metadata Loader Result - * Result of a metadata load operation - */ -export const MetadataLoadResultSchema = lazySchema(() => z.object({ - /** - * Loaded data - */ - data: z.unknown().nullable().describe('Loaded metadata'), - - /** - * Whether data came from cache (304 Not Modified) - */ - fromCache: z.boolean().default(false).describe('Loaded from cache'), - - /** - * Not modified (conditional request matched) - */ - notModified: z.boolean().default(false).describe('Not modified since last request'), - - /** - * ETag of loaded data - */ - etag: z.string().optional().describe('Entity tag'), - - /** - * Statistics about loaded data - */ - stats: MetadataStatsSchema.optional().describe('Metadata statistics'), - - /** - * Load time in milliseconds - */ - loadTime: z.number().min(0).optional().describe('Load duration in ms'), -})); - -/** - * Metadata Save Result - */ -export const MetadataSaveResultSchema = lazySchema(() => z.object({ - /** - * Whether save was successful - */ - success: z.boolean().describe('Save successful'), - - /** - * Path where file was saved - */ - path: z.string().describe('Output path'), - - /** - * Generated ETag - */ - etag: z.string().optional().describe('Generated entity tag'), - - /** - * File size in bytes - */ - size: z.number().int().min(0).optional().describe('File size'), - - /** - * Save time in milliseconds - */ - saveTime: z.number().min(0).optional().describe('Save duration in ms'), - - /** - * Backup path (if created) - */ - backupPath: z.string().optional().describe('Backup file path'), -})); - -/** - * Metadata Watch Event - */ -export const MetadataWatchEventSchema = lazySchema(() => z.object({ - /** - * Event type - */ - type: z.enum(['added', 'changed', 'deleted']).describe('Event type'), - - /** - * Metadata type (e.g., 'object', 'view', 'app') - */ - metadataType: z.string().describe('Type of metadata'), - - /** - * Item name/identifier - */ - name: z.string().describe('Item identifier'), - - /** - * Full file path - */ - path: z.string().describe('File path'), - - /** - * Loaded item data (for added/changed events) - */ - data: z.unknown().optional().describe('Item data'), - - /** - * Timestamp - */ - timestamp: z.string().datetime().describe('Event timestamp'), -})); - -/** - * Metadata Collection Info - * Summary of a metadata collection - */ -export const MetadataCollectionInfoSchema = lazySchema(() => z.object({ - /** - * Collection type (e.g., 'object', 'view', 'app') - */ - type: z.string().describe('Collection type'), - - /** - * Total items in collection - */ - count: z.number().int().min(0).describe('Number of items'), - - /** - * Formats found in collection - */ - formats: z.array(MetadataFormatSchema).describe('Formats in collection'), - - /** - * Total size in bytes - */ - totalSize: z.number().int().min(0).optional().describe('Total size in bytes'), - - /** - * Last modified timestamp - */ - lastModified: z.string().datetime().optional().describe('Last modification date'), - - /** - * Collection location (path or URL) - */ - location: z.string().optional().describe('Collection location'), -})); - -/** - * Metadata Loader Interface Contract - * Defines the standard methods all metadata loaders must implement - */ -export const MetadataLoaderContractSchema = lazySchema(() => z.object({ - /** - * Loader name/identifier - */ - name: z.string().describe('Loader identifier'), - - /** - * Protocol handled by this loader (e.g. 'file:', 'http:', 's3:', 'datasource:') - */ - protocol: z.enum(['file:', 'http:', 's3:', 'datasource:', 'memory:']).describe('Protocol identifier'), - - /** - * Detailed capabilities - */ - capabilities: z.object({ - read: z.boolean().default(true), - write: z.boolean().default(false), - watch: z.boolean().default(false), - list: z.boolean().default(true), - }).describe('Loader capabilities'), - - /** - * Supported formats - */ - supportedFormats: z.array(MetadataFormatSchema).describe('Supported formats'), - - /** - * Whether loader supports watching for changes - */ - supportsWatch: z.boolean().default(false).describe('Supports file watching'), - - /** - * Whether loader supports saving - */ - supportsWrite: z.boolean().default(true).describe('Supports write operations'), - - /** - * Whether loader supports caching - */ - supportsCache: z.boolean().default(true).describe('Supports caching'), -})); +import { lazySchema } from '../shared/lazy-schema'; +// `MetadataManagerConfig.formats` is the ONLY surviving use of a format enum in +// this file. It reads the `shared` copy rather than declaring a fourth one — +// same four members, and `shared` is a leaf module so there is no cycle back +// through `system`. Deliberately NOT the `system` enum: that one is a wider +// superset (`yml`/`ts`/`js` aliases) and adopting it here would silently widen +// what this config accepts. +import { MetadataFormatSchema } from '../shared/metadata-types.zod'; /** * Metadata Fallback Strategy @@ -446,12 +67,12 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ * Root directory for metadata (for filesystem loaders) */ rootDir: z.string().optional().describe('Root directory path'), - + /** * Enabled serialization formats */ formats: z.array(MetadataFormatSchema).default(['typescript', 'json', 'yaml']).describe('Enabled formats'), - + /** * Cache configuration */ @@ -474,12 +95,12 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ ttl: z.number().int().min(0).default(60_000).describe('Cache TTL in milliseconds'), }).optional().describe('DatabaseLoader read-through cache'), }).optional().describe('Cache settings'), - + /** * Watch for file changes */ watch: z.boolean().default(false).describe('Enable file watching'), - + /** * Watch options */ @@ -488,7 +109,7 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ persistent: z.boolean().default(true).describe('Keep process running'), ignoreInitial: z.boolean().default(true).describe('Ignore initial add events'), }).optional().describe('File watcher options'), - + /** * Validation settings */ @@ -496,7 +117,7 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ strict: z.boolean().default(true).describe('Strict validation'), throwOnError: z.boolean().default(true).describe('Throw on validation error'), }).optional().describe('Validation settings'), - + /** * Loader-specific options */ @@ -525,16 +146,5 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ })); // Export types -export type MetadataFormat = z.infer; -export type MetadataStats = z.infer; -export type MetadataLoadOptions = z.input; -export type MetadataSaveOptions = z.infer; -export type MetadataExportOptions = z.infer; -export type MetadataImportOptions = z.infer; -export type MetadataLoadResult = z.infer; -export type MetadataSaveResult = z.infer; -export type MetadataWatchEvent = z.infer; -export type MetadataCollectionInfo = z.infer; -export type MetadataLoaderContract = z.input; export type MetadataManagerConfig = z.input; export type MetadataFallbackStrategy = z.infer; diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index cce0f3c65f..c60cde9699 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -37,9 +37,9 @@ import { ActionSchema } from '../ui/action.zod'; * - **Kubernetes**: API Server + CRD Registry * * ## References - * - kernel/metadata-loader.zod.ts — Storage backend protocol + * - kernel/metadata-loader.zod.ts — MetadataManager wiring (datasource, cache, write gates) * - kernel/metadata-customization.zod.ts — Overlay/merge protocol - * - system/metadata-persistence.zod.ts — Database record format + * - system/metadata-persistence.zod.ts — Database record format + loader/watch envelope types * - contracts/metadata-service.ts — Service interface */ @@ -695,6 +695,40 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // allowRuntimeCreate:false (no runtime "create agent") and // allowOrgOverride:false (no per-org agent fork). The runtime catalog // additionally filters out any non-platform agent record (see service-ai). + // + // FOR AGENTS, THE CODE IS THE RECORD — and that is the whole answer to + // "where is this type's change log?" (#4507). Because the two flags above + // are false, `agent` is the one authorable type with NO governed write + // path: `saveMetaItem` would route it down the legacy raw-engine branch, + // and nothing calls it. The rows are written instead by the shipping + // plugin at boot — `AIStudioPlugin.registerMeta` → `metadataService + // .register()` → `MetadataManager.register` → `DatabaseLoader.save` — + // which writes `sys_metadata` directly with a fresh checksum and appends + // NO `sys_metadata_history` row. So an agent definition that changes + // between releases leaves no metadata-side change log, and there is no + // metadata-side rollback. + // + // That is accepted, not overlooked. These definitions live in version + // control (`@objectstack/service-ai-studio`, `cloud` repo: + // `agents/ask-agent.ts`, `agents/metadata-assistant-agent.ts`), so git + // already holds the full, reviewable history of every change. A second + // history in `sys_metadata` would be a WORSE record, not a better one: it + // would capture only the boots where a given deployment happened to see + // the checksum move, so two deployments on the same release would carry + // different "histories" of an identical, code-fixed definition. Do not add + // one to close a perceived gap. + // + // Two consequences that look like bugs and are not: + // - `migrateStoredMetadata` reports `agent` rows `skipped` ("no repository + // write path"). That is CORRECT AND PERMANENT for this type, not a + // to-do — the pass declines rather than performing a historyless + // rewrite that could also promote a draft. + // - Studio surfaces no History tab for an agent. There is nothing to show; + // the answer to "what changed" is the `cloud` commit log. + // + // If `agent` is ever OPENED to tenant authoring, this note stops applying: + // an author-owned definition has no git to fall back on, so opening the + // type and giving it a real history path are the same piece of work. { type: 'agent', label: 'AI Agent', filePatterns: ['**/*.agent.ts', '**/*.agent.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: true, executionPinned: true, loadOrder: 90, domain: 'ai' }, { type: 'tool', label: 'AI Tool', filePatterns: ['**/*.tool.ts', '**/*.tool.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 85, domain: 'ai' }, { type: 'skill', label: 'AI Skill', filePatterns: ['**/*.skill.ts', '**/*.skill.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 88, domain: 'ai' }, diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts new file mode 100644 index 0000000000..dde8787eac --- /dev/null +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Invariants that must hold for EVERY registered metadata type. + * + * ## The protection-envelope invariant + * + * `MetadataPlugin`'s artifact loader calls `applyProtection` on every registered + * type, and `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying + * the stamped `_packageId` / `_provenance`. A type whose schema does not declare + * {@link MetadataProtectionFields} therefore mishandles it in one of two ways, + * and the severities differ enough to assert separately: + * + * - **Rejects it** (the schema is `.strict()`): a hard 422 on the overlay path. + * Live breakage. Asserted unconditionally — no exemption list. + * - **Does not declare it** (strip mode): the envelope is silently dropped on + * every parse, so protection metadata is lost on round-trip. Quieter, and it + * becomes the first case the day that schema is closed. + * + * ## Why this file exists + * + * The same defect was found four separate times, by four different routes, + * before anyone wrote a check for it: `permission` (#4001 Tier-A, as a hard 422 + * caught by the dogfood gate), `position` (step 2, by reading), `seed` + `doc` + * (the registered-types batch, while converting), and then `hook` + + * `datasource` — which THIS test found on its first run, both already strict on + * `main` and therefore both in the 422 class. + * + * ## Why the declaration check is structural, not a parse probe + * + * The first version of this file probed with one generic body and asked whether + * `_packageId` survived. It reported green. It was hollow: a type whose required + * fields the generic body did not satisfy failed for unrelated reasons, and the + * assertion returned early — **so 24 of 25 types were silently skipped and only + * `field` was ever really checked.** A check that skips is indistinguishable + * from a check that passes, which is the exact defect this whole campaign is + * about, reproduced in the instrument built to detect it. + * + * So the declaration side now walks the schema structurally — unwrapping + * `lazy` / `pipe` / `optional` / `default` and expanding unions — and asks + * whether any resolved object shape declares the key. That answer does not + * depend on constructing a valid instance, so it cannot skip. And a type whose + * shape cannot be resolved at all is a hard FAILURE rather than a pass: the + * walker not understanding a schema is exactly when this test would otherwise + * go quiet. + */ + +import { describe, expect, it } from 'vitest'; + +import { listMetadataTypeSchemaTypes, getMetadataTypeSchema } from './metadata-type-schemas'; + +/** The ADR-0010 stamp the loader puts on every registered item. */ +const STAMP = { _packageId: 'pkg_probe', _provenance: 'package' as const }; + +/** A body generous enough to reach the unknown-key check on most types. */ +const PROBE: Record = { + name: 'probe_item', + label: 'Probe', + object: 'probe_object', + records: [], + content: '# probe', + type: 'text', + ...STAMP, +}; + +/** + * Registered types that parse the envelope but do not declare it, so it is + * dropped on every round-trip. Every entry is a bug awaiting a + * `...MetadataProtectionFields` spread — not a permanent exemption — and each + * becomes a hard 422 the day its schema is closed. Empty this list; never grow + * it. A NEW registered type belongs in neither list. + * + * The structural walk found 8 of these; the probe it replaced had been hiding 7. + * `job` and `book` were closed in the same pass, leaving 6. + */ +const UNDECLARED_ENVELOPE = new Set([ + 'action', 'field', 'mapping', 'page', 'translation', 'validation', +]); + +/** + * Every object shape reachable from `schema`, unwrapping the wrappers the + * registered types actually use and expanding unions. Returns `[]` only when + * the walker does not understand the schema — which the caller treats as a + * failure, never as a pass. + */ +function objectShapes(schema: unknown, depth = 0): Record[] { + if (!schema || depth > 12) return []; + const s = schema as { shape?: Record; _zod?: { def?: Def }; def?: Def }; + const def = s._zod?.def ?? s.def; + switch (def?.type) { + case 'object': + return [s.shape ?? def.shape ?? {}]; + case 'lazy': + try { + return objectShapes(def.getter?.(), depth + 1); + } catch { + return []; + } + case 'pipe': + return [...objectShapes(def.in, depth + 1), ...objectShapes(def.out, depth + 1)]; + case 'union': + return (def.options ?? []).flatMap((o) => objectShapes(o, depth + 1)); + case 'optional': + case 'nullable': + case 'default': + case 'prefault': + case 'readonly': + case 'nonoptional': + case 'catch': + return objectShapes(def.innerType, depth + 1); + default: + return []; + } +} + +interface Def { + type?: string; + shape?: Record; + getter?: () => unknown; + in?: unknown; + out?: unknown; + options?: unknown[]; + innerType?: unknown; +} + +/** `_`-prefixed keys the schema reported as unrecognized, if any. */ +function rejectedEnvelopeKeys(type: string): string[] { + const result = getMetadataTypeSchema(type)!.safeParse(PROBE); + if (result.success) return []; + return result.error.issues + .filter((i) => i.code === 'unrecognized_keys') + .flatMap((i) => (i as unknown as { keys?: string[] }).keys ?? []) + .filter((k) => k.startsWith('_')); +} + +describe('registered metadata types', () => { + const types = listMetadataTypeSchemaTypes(); + + it('is a non-empty set — guards the derivation returning nothing', () => { + expect(types.length).toBeGreaterThan(15); + }); + + it('every registered type resolves to a schema', () => { + for (const type of types) { + expect(getMetadataTypeSchema(type), `no schema registered for '${type}'`).toBeDefined(); + } + }); + + /** + * The no-silent-skip guard. If the walker stops understanding a schema shape, + * the declaration assertions below would quietly stop covering that type — + * so that condition fails here first, loudly, with the type named. + */ + it.each(types)('%s resolves to at least one object shape the walker understands', (type) => { + expect( + objectShapes(getMetadataTypeSchema(type)).length, + `the structural walker cannot resolve '${type}' to an object shape, so the ` + + 'envelope assertions below would silently skip it. Teach `objectShapes` the ' + + 'wrapper this schema uses.', + ).toBeGreaterThan(0); + }); + + it.each(types)('%s does not REJECT the protection envelope its loader stamps', (type) => { + expect( + rejectedEnvelopeKeys(type), + `'${type}' is strict and does not declare the ADR-0010 envelope, so ` + + '`applyProtection` output fails to parse — a hard 422 on the overlay path. ' + + 'Add `...MetadataProtectionFields` to its schema.', + ).toEqual([]); + }); + + it.each(types.filter((t) => !UNDECLARED_ENVELOPE.has(t)))( + '%s DECLARES the protection envelope', + (type) => { + const shapes = objectShapes(getMetadataTypeSchema(type)); + expect( + shapes.some((shape) => '_packageId' in shape), + `'${type}' does not declare \`_packageId\`, so the envelope its loader stamps is ` + + 'dropped on every parse. Add `...MetadataProtectionFields` to its schema.', + ).toBe(true); + }, + ); + + it.each([...UNDECLARED_ENVELOPE])( + '%s is still on the undeclared-envelope debt list (remove it once fixed)', + (type) => { + // A reverse pin: when someone fixes one of these, this fails and forces the + // list to shrink. Without it the debt list would outlive the debt and start + // exempting types that no longer need exempting. + expect(types).toContain(type); + const shapes = objectShapes(getMetadataTypeSchema(type)); + expect( + shapes.some((shape) => '_packageId' in shape), + `'${type}' now declares the envelope — remove it from UNDECLARED_ENVELOPE.`, + ).toBe(false); + }, + ); +}); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 20d57c4c5f..75cfd3872b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -528,7 +528,41 @@ const step17: MigrationStep = { + 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ' + 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ' + 'load path: absorbing them silently would let an author keep believing they configured a ' - + 'timeout.', + + 'timeout.\n\n' + + 'Closing the same audit on the data side, `datasource.readReplicas` is removed (#4468). ' + + 'It described replica connections nothing ever opened: `ConnectableDatasource` and ' + + '`DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the ' + + 'key, and no query path distinguishes a read from a write — read/write splitting does not ' + + 'exist in the platform, so every statement always went to the primary. A lossless delete ' + + 'with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS ' + + 'reader endpoint) and point `config` at it. Notable as the case that shows how a key gets ' + + 'MORE convincing as it stays dead: #4410, closing the datasource-config gap, taught the ' + + 'schema to validate each replica entry against the declared driver\'s config contract, so ' + + 'sources written in between carry replica blocks that were genuinely checked — precise ' + + 'hosts, correct port types, typos rejected. Precision applied to an inert slot reads as ' + + 'evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for ' + + 'rigor. Retired from the load path with the rest of the keys that misdescribed themselves.\n\n' + + 'The `script` flow node converges on its one real path (#4343). It had four ways to name ' + + 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ' + + 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ' + + 'any configuration — with `config.template` / `.recipients` / `.variables` feeding a ' + + 'message no channel ever sent; inline `config.script` was recognized and never executed ' + + '(the built-in runtime has no server-side JS sandbox), so the node warned and no-op\'d; and ' + + 'every other `actionType` value was shorthand for a registered-function name, a second ' + + 'spelling of `config.function`. All five keys are retired and `function` becomes required, ' + + 'which is also what finally made the contract PARSEABLE: while the legal key set depended ' + + 'on `actionType`, a flat parse would either reject valid shapes or wave everything through, ' + + 'so `script` (with `subflow`) now runs through the same execute-time contract parse #4277 ' + + 'gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what ' + + 'it meant — unless `function` is already set, in which case it was dead metadata the ' + + 'executor never reached. The other four are dropped outright: nothing read them, so there ' + + 'is no value to preserve, and rebuilding the intent is an authoring decision the tombstones ' + + 'prescribe per branch (a `notify` node for mail — it delivers through the messaging ' + + 'service, the in-app inbox by default and real email once `@objectstack/plugin-email` is ' + + 'installed; a `connector_action` with the Slack connector, or an `http` node posting to a ' + + 'webhook, for Slack; a registered function for an inline body). Retired from the load path ' + + 'for the same reason as the rest: absorbing `actionType: \'email\'` silently would let an ' + + 'author keep believing the flow sends mail.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -553,6 +587,8 @@ const step17: MigrationStep = { 'skill-trigger-phrases-removed', 'stack-api-require-auth-removed', 'flow-node-wait-timeout-keys-removed', + 'datasource-read-replicas-removed', + 'flow-node-script-branch-keys-removed', ], semantic: [ { @@ -738,6 +774,36 @@ const step17: MigrationStep = { + 'door. A query still carrying the key fails to parse with the removal prescription, ' + 'and the REST list response reports a real `total` for queries that used to send it.', }, + { + id: 'workflow-service-slot-retired', + surface: + "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / " + + 'discovery routes.workflow / RestApiRouteCategory workflow', + replacement: + 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules ' + + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) ' + + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for ' + + 'record-triggered automation', + reason: + 'The workflow slot was declared end to end and implemented nowhere: no code in either ' + + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' + + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' + + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' + + 'mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among ' + + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' + + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' + + 'protocol nothing served, a discovery route field no builder could truthfully ' + + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' + + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' + + 'deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451.', + acceptanceCriteria: + 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' + + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' + + 'discovery `routes.workflow` / `services.workflow`; record state machines, ' + + 'approvals and record-triggered automation go through the replacement mechanisms. ' + + 'Discovery output on a default boot is unchanged (the slot was always reported ' + + 'unavailable; now it is simply absent).', + }, ], }; diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts new file mode 100644 index 0000000000..a7ab7922bb --- /dev/null +++ b/packages/spec/src/shared/strict-object.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { describe, expect, it } from 'vitest'; + +import { lazySchema } from './lazy-schema'; +import { strictObject } from './strict-object'; + +const WidgetSchema = lazySchema(() => + strictObject( + { + surface: 'this widget', + history: 'Until #4001 these were dropped silently — the widget still rendered.', + aliases: { visibleWhen: 'visible' }, + guidance: { span: '`span` was retired in vX. Use `columnSpan`.' }, + }, + { + name: z.string(), + visible: z.boolean().optional(), + columnSpan: z.number().optional(), + }, + ), +); + +/** Composition fixtures — the shapes real schema files actually build. */ +const Described = lazySchema(() => + strictObject({ surface: 'a described surface', history: 'h' }, { a: z.string() }).describe('d'), +); +const Refined = lazySchema(() => + strictObject({ surface: 'a refined surface', history: 'h' }, { + a: z.string(), + b: z.string().optional(), + }).superRefine((v, ctx) => { + if (!v.b) ctx.addIssue({ code: 'custom', path: ['b'], message: 'need b' }); + }), +); +const Extended = lazySchema(() => + strictObject({ surface: 'a base surface', history: 'h' }, { a: z.string() }) + .extend({ c: z.string().optional() }), +); + +describe('strictObject', () => { + it('rejects an unknown key, naming the surface and echoing the key', () => { + const r = WidgetSchema.safeParse({ name: 'x', nonsense: 1 }); + expect(r.success).toBe(false); + expect(r.error!.issues[0].message).toContain('this widget'); + expect(r.error!.issues[0].message).toContain('`nonsense`'); + }); + + it('suggests the closest key from the SHAPE — no transcribed key list', () => { + const r = WidgetSchema.safeParse({ name: 'x', colummSpan: 2 }); + expect(r.error!.issues[0].message).toContain('`colummSpan` → `columnSpan`'); + }); + + it('still honours a curated alias', () => { + const r = WidgetSchema.safeParse({ name: 'x', visibleWhen: true }); + expect(r.error!.issues[0].message).toContain('`visibleWhen` → `visible`'); + }); + + it('still honours a tombstone, and suppresses the rename for it', () => { + const r = WidgetSchema.safeParse({ name: 'x', span: 2 }); + const msg = r.error!.issues[0].message; + expect(msg).toContain('`span` was retired'); + expect(msg).not.toContain('→'); + }); + + /** + * The structural replacement for the "accepts every declared key" probe each + * hand-transcribed key array needed. Asserted once, here, instead of per + * schema: a key list read from the shape cannot disagree with it. + */ + it('accepts every key the shape declares', () => { + expect(WidgetSchema.safeParse({ name: 'x', visible: true, columnSpan: 1 }).success).toBe(true); + }); + + it('preserves type inference', () => { + const v: z.infer = { name: 'x' }; + expect(v.name).toBe('x'); + }); + + it('takes extraKeys as additional suggestion candidates', () => { + const Base = lazySchema(() => + strictObject({ surface: 'a base', history: 'h', extraKeys: ['inherited'] }, { a: z.string() }), + ); + // `inherited` is not declared here, so it is still rejected … + expect(Base.safeParse({ a: '1', inherited: 2 }).success).toBe(false); + // … but a near-miss of it resolves, which is what extraKeys is for. + expect(Base.safeParse({ a: '1', inherted: 2 }).error!.issues[0].message) + .toContain('`inherted` → `inherited`'); + }); + + describe('composition — the shapes real schema files use', () => { + it('survives .describe()', () => { + expect(Described.safeParse({ a: '1' }).success).toBe(true); + expect(Described.safeParse({ a: '1', z: 2 }).success).toBe(false); + }); + + it('survives .superRefine() — both the refinement and strictness fire', () => { + expect(Refined.safeParse({ a: '1' }).success).toBe(false); + expect(Refined.safeParse({ a: '1', b: '2' }).success).toBe(true); + expect(Refined.safeParse({ a: '1', b: '2', zz: 1 }).success).toBe(false); + }); + + /** + * `.extend()` INHERITS strictness. The ledger flags this as the trap to + * watch when batching: a response-side extension of an authoring schema + * must `.strip()` back, or a wire shape silently goes strict and an + * upstream field addition becomes a parse crash. + */ + it('propagates strictness through .extend()', () => { + expect(Extended.safeParse({ a: '1', c: '2' }).success).toBe(true); + expect(Extended.safeParse({ a: '1', nope: 1 }).success).toBe(false); + }); + + it('can be strip()ped back for a response-side extension', () => { + const Wire = lazySchema(() => + strictObject({ surface: 's', history: 'h' }, { a: z.string() }) + .extend({ serverOnly: z.string().optional() }) + .strip(), + ); + expect(Wire.safeParse({ a: '1', addedUpstreamLater: true }).success).toBe(true); + }); + }); + + /** + * ADR-0089 D3a hit `Cannot set properties of undefined (setting 'ref')` when + * `.strict()` pipelines met zod's `toJSONSchema` traversal over the lazySchema + * Proxy. The ledger names it as the hazard to watch while batching, so it is + * pinned here rather than rediscovered per conversion. + */ + it('converts to JSON Schema through the lazy proxy without throwing', () => { + for (const s of [WidgetSchema, Described, Refined, Extended] as unknown as z.ZodTypeAny[]) { + expect(() => z.toJSONSchema(s)).not.toThrow(); + } + }); + + /** + * Recorded in the ledger during the datasource step and load-bearing for the + * whole campaign: strictness does NOT widen or narrow the published JSON + * Schema. `build-schemas.ts` converts with `io: 'output'`, and output mode + * already emits `additionalProperties: false` for a `.strip()` object — so + * these flips align the parse with a contract that was already published, + * rather than changing it. + */ + it('emits additionalProperties: false, which strip mode already published', () => { + const strictJson = z.toJSONSchema(WidgetSchema as unknown as z.ZodTypeAny) as { + additionalProperties?: unknown; + }; + const stripJson = z.toJSONSchema(z.object({ name: z.string() })) as { + additionalProperties?: unknown; + }; + expect(strictJson.additionalProperties).toBe(false); + expect(stripJson.additionalProperties).toBe(false); + }); +}); diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts new file mode 100644 index 0000000000..350948e893 --- /dev/null +++ b/packages/spec/src/shared/strict-object.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `strictObject` — one call to close an authoring shape against unknown keys. + * + * ## Why this exists + * + * The #4001 campaign's standard wiring was four moving parts per schema: a + * hand-transcribed `const X_KEYS = [...] as const` array, a + * `strictUnknownKeyError({ surface, knownKeys: X_KEYS, history })` call, the + * `{ error }` argument, and `.strict()`. Plus — because the transcribed array + * can drift from the shape it describes — an "accepts every declared key" probe + * test to catch the drift. + * + * That was ~34 key arrays and 16 drift-probe test files at the point this + * helper was written, for a campaign with most of its authorable surface still + * ahead of it. The cost is not the typing; it is that **the array is a second + * copy of the truth**, so every schema edit is two edits, and the probe test + * exists only to catch the case where someone made one of them. + * + * The array was never necessary. `knownKeys` feeds one thing — the + * edit-distance "did you mean" fallback — and the shape object is right there + * at the call site. Deriving the keys from the shape makes the two copies one, + * which is also why **no drift probe is needed for a schema built this way**: + * the key list cannot disagree with the shape it was read from. + * + * ## What it does not replace + * + * `aliases` and `guidance` stay hand-written, because they are the part that + * carries judgement rather than transcription: + * + * - `aliases` — semantic near-misses edit distance cannot reach. The one that + * proves the category is `visibleWhen → visible`: ADR-0089 made `visibleWhen` + * the correct spelling on view/page, so an author borrowing it on a different + * surface is not making a typo, and only a human-written entry can catch it. + * - `guidance` — tombstones for retired keys (the rejection must carry the + * upgrade) and wrong-layer pointers. + * + * Both are optional. A schema with neither still gets a named surface, the + * offending key echoed back, and a distance-based suggestion — which is the + * difference between a silent strip and a fixable error. Curation is an + * upgrade, not a precondition, and treating it as a precondition is part of why + * the ratchet moved as slowly as it did. + * + * @example + * ```ts + * export const WidgetSchema = lazySchema(() => strictObject( + * { + * surface: 'this widget', + * history: 'Until #4001 these were dropped silently — the widget still rendered.', + * aliases: { visibleWhen: 'visible' }, + * }, + * { + * name: z.string(), + * visible: z.boolean().optional(), + * }, + * )); + * ``` + */ + +import { z } from 'zod'; + +import { strictUnknownKeyError } from './suggestions.zod'; + +/** Authoring-surface metadata for {@link strictObject}. */ +export interface StrictObjectOptions { + /** Prose name of the surface the key was written on (e.g. `'this widget'`). */ + surface: string; + /** One sentence: what silently happened before this shape was closed. */ + history: string; + /** + * Semantic near-misses edit distance cannot reach — a different *word* for + * the same intent, usually correct on a neighbouring surface. + */ + aliases?: Readonly>; + /** + * Exact-key prescriptions appended as bullet lines: tombstones for retired + * keys, wrong-layer pointers. An entry here suppresses the rename suggestion. + */ + guidance?: Readonly>; + /** + * Extra candidates for the "did you mean" fallback beyond the shape's own + * keys. For a base that gets `.extend()`ed elsewhere, naming the extension's + * keys here keeps the suggestion useful on the extended surface. + */ + extraKeys?: readonly string[]; +} + +/** + * A `.strict()` object whose unknown-key error names the surface, echoes the + * offending key, and suggests the closest declared key — with the candidate + * list read from `shape` rather than transcribed alongside it. + */ +export function strictObject(options: StrictObjectOptions, shape: T) { + const { surface, history, aliases, guidance, extraKeys = [] } = options; + return z + .object(shape, { + error: strictUnknownKeyError({ + surface, + knownKeys: [...Object.keys(shape), ...extraKeys], + history, + aliases, + guidance, + }), + }) + .strict(); +} diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 87ad2cdbc4..9c7a23abb2 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** * Package Documentation Navigation — the `book` element (ADR-0046 §6). @@ -115,6 +116,13 @@ export const BookSchema = lazySchema(() => order: z.number().optional().describe('Orders books within the portal'), audience: BookAudienceSchema.optional().describe("Access audience; defaults to 'org' (inherits package grant)"), groups: z.array(BookGroupSchema).describe('The spine: ordered sections. Two levels total.'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `book` is a registered metadata type, so the artifact loader stamps + // `_packageId` / `_provenance` on it like every sibling. Undeclared, they + // were dropped on every parse — protection metadata lost on round-trip, and + // a hard 422 waiting for the day this shape is closed. + ...MetadataProtectionFields, }), ); diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index cb171c496b..68f36727ad 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -78,8 +78,11 @@ describe('SystemFieldName', () => { it('should expose all expected field names', () => { expect(SystemFieldName.ID).toBe('id'); expect(SystemFieldName.CREATED_AT).toBe('created_at'); + expect(SystemFieldName.CREATED_BY).toBe('created_by'); expect(SystemFieldName.UPDATED_AT).toBe('updated_at'); + expect(SystemFieldName.UPDATED_BY).toBe('updated_by'); expect(SystemFieldName.OWNER_ID).toBe('owner_id'); + expect(SystemFieldName.ORGANIZATION_ID).toBe('organization_id'); expect(SystemFieldName.TENANT_ID).toBe('tenant_id'); expect(SystemFieldName.USER_ID).toBe('user_id'); expect(SystemFieldName.DELETED_AT).toBe('deleted_at'); @@ -90,6 +93,30 @@ describe('SystemFieldName', () => { expect(names).toContain('id'); expect(names).toContain('owner_id'); }); + + // The gap this table carried until #4443: `applySystemFields` injects + // `organization_id` as THE tenant key and `created_by` / `updated_by` as + // audit provenance, yet none of the three had a canonical constant — while + // `tenant_id`, which open-core never injects, was documented as "Tenant + // isolation key". Consumers hand-copying a system-field list read that as + // gospel and drifted: cloud#982 found three copies carrying `tenant_id`, + // `org_id` and `space`, none of which any injection site produces. + it('names every column the registry actually injects', () => { + const names: readonly string[] = Object.values(SystemFieldName); + // Mirrors applySystemFields' injection set (objectql registry). That + // package's own conformance test enumerates the set from the live code; + // this one only asserts the protocol table has a spelling for each member. + for (const injected of [ + 'organization_id', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'owner_id', + ]) { + expect(names, injected).toContain(injected); + } + }); }); // ============================================================================ diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 53731deb47..28bce71f91 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -110,14 +110,48 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId]; /** * System Field Names — Protocol Layer Constants * - * These constants define the canonical, protocol-level names for common system fields. - * All API calls, SDK references, and permission checks MUST use these constants - * instead of hardcoded strings or physical column names. + * The canonical, protocol-level SPELLING of each column the platform manages + * rather than the author. All API calls, SDK references, and permission checks + * MUST use these constants instead of hardcoded strings or physical column + * names. * * The physical storage column always equals the field key (the driver does not * support per-field column overrides; external objects map columns via * `external.columnMap`, ADR-0062 D7 / ADR-0015). * + * ## ⚠️ This is a NAME registry, not the injected-column SET + * + * WHICH of these columns exists on a given object is decided PER OBJECT by + * `applySystemFields()` (`@objectstack/objectql` registry), from that object's + * own declarations: `ownership: 'org' | 'none'` withholds `owner_id`, + * `tenancy.enabled: false` withholds `organization_id`, and + * `systemFields: false` / `managedBy: 'better-auth'` disable the pass + * entirely. So the SAME NAME can be a system column on one object and an + * ordinary authored business field on another — a business field named + * `owner`, say (cloud#979, an observed failure: it was treated as a system + * column, so seeded rows left it blank). + * + * A consumer asking **"is this field system-managed ON THIS OBJECT?"** must + * therefore NOT test membership in this table. Branch on the per-field + * `Field.system` flag (`@objectstack/spec/data`) — `applySystemFields` stamps + * it on every column it injects, and no authored field carries it. That flag + * is the per-object answer; this table only answers "what is the canonical + * spelling of the column that plays role X". + * + * Related declarations, each with a different job — do not conflate them: + * - `FIELD_GROUP_SYSTEM_FIELDS` (`@objectstack/spec/data`) — names excluded + * from a default form/detail layout. + * - `PUBLIC_FORM_SERVER_MANAGED_FIELDS` (`@objectstack/spec/security`) — names + * never client-suppliable on the anonymous surface, pinned by objectql's + * `system-managed-fields-conformance.test.ts` to be exactly (what open-core + * injects ∪ documented reserved names). + * + * Every entry below records whether open-core actually INJECTS it, because + * that is the distinction hand-copied lists keep getting wrong + * (framework#4330, cloud#982 — where three copies had drifted onto + * `tenant_id`/`org_id`/`space`, two of which no injection site has ever + * produced). + * * @example * ```ts * import { SystemFieldName } from '@objectstack/spec/system'; @@ -129,19 +163,44 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId]; * ``` */ export const SystemFieldName = { - /** Primary key */ + /** Primary key. Provisioned by the driver, not by `applySystemFields`. */ ID: 'id', - /** Record creation timestamp */ + /** Record creation timestamp. INJECTED (audit provenance). */ CREATED_AT: 'created_at', - /** Record last-updated timestamp */ + /** User who created the record (lookup to user). INJECTED (audit provenance). */ + CREATED_BY: 'created_by', + /** Record last-updated timestamp. INJECTED (audit provenance). */ UPDATED_AT: 'updated_at', - /** Record owner (lookup to user) */ + /** User who last modified the record (lookup to user). INJECTED (audit provenance). */ + UPDATED_BY: 'updated_by', + /** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */ OWNER_ID: 'owner_id', - /** Tenant isolation key */ + /** + * THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless + * tenancy is disabled for the object; org-scoping populates it on insert and + * it stays NULL on single-tenant stacks. + */ + ORGANIZATION_ID: 'organization_id', + /** + * Legacy / enterprise tenant alias. **NOT injected by open-core** — nothing + * provisions this column. It is stamped (from the session's *organization* + * id, `objectql` plugin) only on an object that DECLARES it, and it stays on + * the public-form denylist as defense-in-depth. Use + * {@link SystemFieldName.ORGANIZATION_ID} for tenant scoping; this constant + * exists so the legacy spelling still has one canonical reference. + */ TENANT_ID: 'tenant_id', - /** Foreign key to user on session / account objects */ + /** + * Foreign key to user on session / account objects. **Authored**, not + * injected — an ordinary business object may legitimately declare its own + * `user_id` lookup, so this name must never be used to classify a column as + * system-managed. + */ USER_ID: 'user_id', - /** Soft-delete timestamp */ + /** + * Soft-delete timestamp. **NOT injected by `applySystemFields`** — written by + * the lifecycle / trash layer at runtime. + */ DELETED_AT: 'deleted_at', } as const; diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts index fc555db15e..d979eb90fb 100644 --- a/packages/spec/src/system/core-service-provider.test.ts +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -22,17 +22,29 @@ describe('CORE_SERVICE_PROVIDER', () => { expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging'); }); + // ['workflow', 'graphql'] left this list in #4451 (v17): the workflow slot + // was retired outright, and graphql was never a CoreServiceName — its + // stray entry here named a surface the dispatcher had already removed. it('uses null — not a plausible name — where no package can be installed', () => { - for (const slot of ['ai', 'search', 'workflow', 'graphql']) { + for (const slot of ['ai', 'search']) { expect(CORE_SERVICE_PROVIDER[slot], `${slot} must name no installable package`).toBeNull(); } }); + it('carries no entry for retired or never-real slots (#4451)', () => { + for (const slot of ['workflow', 'graphql']) { + expect( + Object.prototype.hasOwnProperty.call(CORE_SERVICE_PROVIDER, slot), + `${slot} must have no entry`, + ).toBe(false); + } + }); + // `null` covers two different situations, and only one of them means // "nothing exists". Verified against objectstack-ai/cloud: nothing there - // registers search/workflow/graphql, but `@objectstack/service-ai` does - // register `ai` — it is simply `private: true`, so there is no package to - // install and no name that belongs in an "Install X" sentence. + // registers search, but `@objectstack/service-ai` does register `ai` — it + // is simply `private: true`, so there is no package to install and no + // name that belongs in an "Install X" sentence. it('still says something ships for a slot whose provider is real but uninstallable', () => { const ai = serviceUnavailableMessage('ai'); expect(ai).not.toMatch(/No implementation ships/); @@ -41,10 +53,8 @@ describe('CORE_SERVICE_PROVIDER', () => { // Still not an instruction a reader could act on and fail at. expect(ai).not.toMatch(/^Install /); - // The genuinely-empty slots keep the plain sentence. - for (const slot of ['search', 'workflow', 'graphql']) { - expect(serviceUnavailableMessage(slot), slot).toMatch(/No implementation ships/); - } + // The genuinely-empty slot keeps the plain sentence. + expect(serviceUnavailableMessage('search')).toMatch(/No implementation ships/); }); it('scopes every named package, so the remedy is copy-pasteable', () => { diff --git a/packages/spec/src/system/core-services.test.ts b/packages/spec/src/system/core-services.test.ts index a25ae1a6af..a83efa52a2 100644 --- a/packages/spec/src/system/core-services.test.ts +++ b/packages/spec/src/system/core-services.test.ts @@ -10,11 +10,12 @@ import { describe('CoreServiceName', () => { it('should accept all valid service names', () => { + // ('workflow' retired with its slot, #4451 v17.) const services = [ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', 'automation', 'analytics', 'realtime', - 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow', + 'job', 'notification', 'ai', 'i18n', 'ui', ]; services.forEach((service) => { @@ -67,7 +68,8 @@ describe('ServiceRequirementDef', () => { expect(ServiceRequirementDef.notification).toBe('optional'); expect(ServiceRequirementDef.ai).toBe('optional'); expect(ServiceRequirementDef.ui).toBe('optional'); - expect(ServiceRequirementDef.workflow).toBe('optional'); + // `workflow` retired with its slot (#4451, v17). + expect(ServiceRequirementDef).not.toHaveProperty('workflow'); }); }); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 4a0e2e8e91..3ff7aa571a 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -38,7 +38,13 @@ export const CoreServiceName = z.enum([ 'ai', // AI Engine (NLQ, Chat, Suggest, Insights) 'i18n', // Internationalization Service 'ui', // UI Metadata Service (View CRUD) - 'workflow', // Workflow State Machine Engine + // `workflow` (Workflow State Machine Engine) retired in #4451 (v17): + // nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no + // provider ships in either repository, and the capability is live elsewhere + // — `state_machine` validation rules, approval flow nodes (ADR-0019), + // lifecycle hooks + `record_change` flows (service-automation). + // (Backticks, not quotes: scripts/check-service-providers.mjs reads every + // quoted token in this block as an enum member, comments included.) ]); export type CoreServiceName = z.infer; @@ -94,10 +100,9 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { // different situations — see REMEDY_DETAIL, which is how the second one still // gets an accurate message. // - // Nothing provides the slot at all: `search`, `workflow` (no consumer - // either — ADR-0115 Evidence 5) and `graphql` (a surface with no provider). - // Verified across BOTH repositories: nothing in `objectstack-ai/cloud` - // registers them. + // Nothing provides the slot at all: `search` (no consumer either — + // ADR-0115 Evidence 5). Verified across BOTH repositories: nothing in + // `objectstack-ai/cloud` registers it. // // A provider exists but cannot be installed: `ai`. `@objectstack/service-ai` // registers this slot in `objectstack-ai/cloud` and is `private: true`, so @@ -105,10 +110,16 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { // exact failure this table was written to end. It carries a REMEDY_DETAIL // sentence instead; a bare `null` here would tell a Cloud/Enterprise // deployment that nothing ships, which is false. + // + // Two entries left in #4451 (v17). `graphql` was never a `CoreServiceName` + // — it existed only here and in metadata-protocol's discovery table, naming + // a `/graphql` surface the dispatcher had already removed as out of the + // product plan (#2462 follow-on); this table's guard + // (`scripts/check-service-providers.mjs`) only checks that every SLOT has an + // entry, never that every entry is a slot, so the stray sat unchallenged. + // `workflow` retired with its slot — see the CoreServiceName note above. 'ai': null, 'search': null, - 'workflow': null, - 'graphql': null, } as const; /** @@ -148,6 +159,27 @@ export function serviceUnavailableMessage(slot: string): string { : `No implementation ships for the '${slot}' slot — register a service under it to enable`; } +/** + * The message discovery reports for an occupied slot that is kernel-internal + * by construction — `cache`, `queue`, `job` (#4318). Their shipped providers + * (see {@link CORE_SERVICE_PROVIDER}) mount no HTTP routes: the slots are + * consumed in-process via the service registry, so no route is ever advertised + * for them (ADR-0076 D12) and `handlerReady` is reported `false` — for these + * slots it is not a proxy for anything, it is the fact itself. + * + * An unmarked occupant still reports `available`: the slot's contract is + * in-process, so "no HTTP surface" is not reduced capability. Contrast + * `realtime`, whose advertised capability IS the missing HTTP/WS surface — + * there an in-process bus reports `degraded`. + * + * Written once here so the two discovery builders (`HttpDispatcher` and the + * metadata-protocol implementation) cannot drift apart — the same reason + * {@link serviceUnavailableMessage} lives here (#4089, #4130). + */ +export function inProcessServiceMessage(slot: string): string { + return `Kernel-internal service — consumed in-process via the service registry; no HTTP surface exists for the '${slot}' slot`; +} + /** * Service Criticality Level * Defines the startup behavior when a service is missing. @@ -184,7 +216,6 @@ export const ServiceRequirementDef = { notification: 'optional', ai: 'optional', ui: 'optional', - workflow: 'optional', } as const; // ========================================== diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index bd48a566e6..d0ebc476e2 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** * Package Documentation Metadata Protocol (ADR-0046) @@ -25,7 +27,34 @@ import { lazySchema } from '../shared/lazy-schema'; * resolve relative links between docs (`[guide](./crm_lead_guide.md)`) * by stripping `./` and `.md` to obtain the target doc name. */ -export const DocSchema = lazySchema(() => z.object({ +export const DocSchema = lazySchema(() => strictObject({ + surface: 'this doc', + history: + 'Until #4001 these were dropped silently — the doc still registered, just without ' + + 'whatever the key was meant to configure.', + aliases: { + title: 'label', + heading: 'label', + body: 'content', + markdown: 'content', + md: 'content', + text: 'content', + summary: 'description', + sort: 'order', + sortorder: 'order', + position: 'order', + category: 'group', + section: 'group', + i18n: 'translations', + locales: 'translations', + }, + guidance: { + path: + '`path` is not a doc key. A doc\'s identity is its `name` (the source filename stem) — ' + + 'ADR-0046 keeps `src/docs/` flat precisely so there is no path to record.', + slug: '`slug` is not a doc key. Use `name`; it is the filename stem and the resolution key.', + }, +}, { /** * Doc name; equals the source filename stem. Lowercase snake_case. A * namespace prefix (e.g. `crm_lead_guide`) is recommended for readable, @@ -95,6 +124,13 @@ export const DocSchema = lazySchema(() => z.object({ ) .optional() .describe('Per-locale {label?,description?,content} variants; the base doc is the fallback'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // See the note on `SeedSchema`: every registered metadata type gets stamped by + // `MetadataPlugin`'s artifact loader, and an undeclared envelope is stripped + // on every parse — the inverse drift that made `permission` 422 when it went + // strict (#4001 findings log, entry 2). + ...MetadataProtectionFields, })); export type Doc = z.infer; export type DocTranslation = NonNullable[string]; diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index e8ee30c9f2..350f4a7030 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -8,6 +8,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * Schedule jobs using cron expressions */ import { lazySchema } from '../shared/lazy-schema'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const CronScheduleSchema = lazySchema(() => z.object({ type: z.literal('cron'), expression: CronExpressionInputSchema.describe('Cron expression — cron`0 0 * * *` for daily at midnight. Build emits {dialect:"cron",source} envelope.'), @@ -90,6 +91,14 @@ export const JobSchema = lazySchema(() => z.object({ retryPolicy: RetryPolicySchema.optional().describe('Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = backoffMs * backoffMultiplier^(retry-1)) up to maxRetries retries after the initial attempt (#3494). Omit for the legacy single-attempt behavior.'), timeout: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit.'), enabled: z.boolean().default(true).describe('Whether the job is enabled'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `job` is a registered metadata type, so `MetadataPlugin`'s artifact loader + // stamps `_packageId` / `_provenance` on it like every sibling. Undeclared, + // they were dropped on every parse: protection metadata lost on round-trip, + // and a hard 422 waiting for the day this shape is closed (see + // `metadata-type-schemas.test.ts` for the invariant and how it was hollow). + ...MetadataProtectionFields, })); export type Job = z.infer; diff --git a/packages/spec/src/system/metadata-persistence.zod.ts b/packages/spec/src/system/metadata-persistence.zod.ts index ed0e917a63..b94c8fc6c1 100644 --- a/packages/spec/src/system/metadata-persistence.zod.ts +++ b/packages/spec/src/system/metadata-persistence.zod.ts @@ -161,6 +161,15 @@ export const PackagePublishResultSchema = lazySchema(() => z.object({ export type PackagePublishResult = z.infer; +// ─── Loader / watch envelope types ─────────────────────────────────────────── +// +// Everything from here to `MetadataSource` below is the SINGLE source for the +// metadata loader + watch vocabulary. `kernel/metadata-loader.zod` used to +// declare a differently-shaped copy of each of these names on the +// `@objectstack/spec/kernel` entry — an import-path coin-flip that no consumer +// ever won on purpose (every one of them imported from here). The kernel copies +// had zero consumers and were removed in #4411; keep new envelope types here. + /** * Metadata Format * Supported file formats for metadata serialization. @@ -322,6 +331,10 @@ export const MetadataSourceSchema = lazySchema(() => z.enum([ * historically declared a narrower duplicate; we re-export the kernel version * here so a single TypeScript type is observed everywhere `@objectstack/spec` * consumers reach for it. + * + * This pair is the ONLY thing this file takes from kernel, and it is the + * direction that survived #4411: manager *wiring* is owned by kernel, the + * loader/watch *envelope* is owned here. Nothing is declared twice. */ export { MetadataFallbackStrategySchema, diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index 8bee270bdb..12bd5b5c28 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -98,6 +98,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)', invalid_time: '{{label}} must be a valid time (HH:MM or HH:MM:SS)', invalid_option: '{{label}} must be one of: {{allowed}}', + reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"', invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}', option_unavailable: "{{label}}: option '{{value}}' is not available", invalid_type_array: '{{label}} must be an array of values', @@ -132,6 +133,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)', invalid_time: '{{label}}必须是有效的时间(HH:MM 或 HH:MM:SS)', invalid_option: '{{label}}必须是以下值之一:{{allowed}}', + reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录', invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}', option_unavailable: '{{label}}:选项“{{value}}”当前不可用', invalid_type_array: '{{label}}必须是数组', @@ -163,6 +165,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください', invalid_time: '{{label}}は有効な時刻(HH:MM または HH:MM:SS)を入力してください', invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}', + reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません', invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})', option_unavailable: '{{label}}:選択肢「{{value}}」は現在利用できません', invalid_type_array: '{{label}}は配列で指定してください', @@ -194,6 +197,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)', invalid_time: '{{label}} debe ser una hora válida (HH:MM o HH:MM:SS)', invalid_option: '{{label}} debe ser uno de: {{allowed}}', + reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»', invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}', option_unavailable: '{{label}}: la opción «{{value}}» no está disponible', invalid_type_array: '{{label}} debe ser una lista de valores', diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 1fd6b25477..f707d2c69f 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -65,6 +65,19 @@ describe('validateActionParams (ADR-0104 D2)', () => { expect(ACTION_PARAM_BUILTIN_KEYS).toContain('objectName'); }); + it('allows the aggregate-dispatch key _selectedIds on a param-declaring action (objectui#3139)', () => { + // The renderer's aggregate bulk dispatch injects `_selectedIds` next to + // the user-collected params; strict mode must not 400 the whole call for + // a key the author can never declare. + const resolved: ResolvedActionParam[] = [{ name: 'format', type: 'text' }]; + const issues = validateActionParams(resolved, { + format: 'png', + _selectedIds: ['dev_1', 'dev_2'], + }); + expect(issues).toEqual([]); + expect(ACTION_PARAM_BUILTIN_KEYS).toContain('_selectedIds'); + }); + it('leaves the value shape OPEN when the resolved type is unknown (field-backed param whose field is gone)', () => { const resolved: ResolvedActionParam[] = [{ name: 'freeform' /* no type */ }]; expect(validateActionParams(resolved, { freeform: { anything: [1, 2, 3] } })).toEqual([]); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index 0ad52c079e..3485a01a99 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -59,8 +59,15 @@ export interface ActionParamIssue { /** * Keys the dispatcher merges into the params bag itself (never authored by the * caller) — always permitted, never flagged as unknown. + * + * `_selectedIds` is injected by the renderer's aggregate bulk dispatch + * (objectui#3139): a `bulkActionDefs` entry with `execution: 'aggregate'` + * calls the action ONCE for the whole selection, carrying every selected + * record id in this key so the handler can produce a single aggregate + * artifact (zip of QR codes, merged PDF…). Server handlers read it from + * `ctx.params._selectedIds`; it is never authored as a declared param. */ -export const ACTION_PARAM_BUILTIN_KEYS: readonly string[] = ['recordId', 'objectName']; +export const ACTION_PARAM_BUILTIN_KEYS: readonly string[] = ['recordId', 'objectName', '_selectedIds']; function isPresent(v: unknown): boolean { return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); diff --git a/packages/spec/src/ui/bulk-action.test.ts b/packages/spec/src/ui/bulk-action.test.ts new file mode 100644 index 0000000000..4cf473ca3a --- /dev/null +++ b/packages/spec/src/ui/bulk-action.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { BulkActionDefSchema } from './bulk-action.zod'; + +/** Parse and return the flattened issue messages (empty = clean). */ +const reject = (input: unknown): string[] => { + const r = BulkActionDefSchema.safeParse(input); + return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); +}; + +const ok = (input: unknown) => { + const r = BulkActionDefSchema.safeParse(input); + if (!r.success) throw new Error(`expected parse to succeed:\n${r.error.issues.map((i) => i.message).join('\n')}`); + return r.data; +}; + +describe('BulkActionDefSchema (#4457)', () => { + describe('— the shapes real views author today keep parsing', () => { + // Lifted verbatim from `examples/app-showcase/src/ui/views/project.view.ts` + // and `task.view.ts`. Typing a key that was `z.record(z.any())` is only + // safe if the surface it governed still fits, so the specimens are the + // regression guard, not a hand-written approximation. + it('accepts the showcase aggregate def', () => { + expect(ok({ name: 'showcase_recalc_selection', operation: 'custom', execution: 'aggregate' })) + .toMatchObject({ name: 'showcase_recalc_selection', execution: 'aggregate' }); + }); + + it('accepts a mass update with a select param', () => { + const def = ok({ + name: 'set_labels', + label: 'Set Labels', + operation: 'update', + confirmText: 'Set these labels on every selected project?', + params: [{ + name: 'labels', + label: 'Labels', + type: 'select', + multiple: true, + required: true, + options: [{ label: 'Frontend', value: 'frontend' }, { label: 'QA', value: 'qa' }], + }], + }); + expect(def.params?.[0]).toMatchObject({ name: 'labels', type: 'select', multiple: true }); + }); + + it('accepts a lookup param with `object` / `labelField`', () => { + // The bulk-dialog spelling of what an ActionParam calls `reference`. + // Documented divergence — see the module header; the point of typing it + // is that the divergence is now written down instead of implied. + const def = ok({ + name: 'assign_team', + operation: 'update', + params: [{ name: 'team_members', type: 'lookup', object: 'sys_user', labelField: 'name', multiple: true }], + }); + expect(def.params?.[0]).toMatchObject({ object: 'sys_user', labelField: 'name' }); + }); + + it('forwards unknown WIDGET config on a param — the renderer declares a catch-all', () => { + const def = ok({ + name: 'reschedule', + operation: 'update', + params: [{ name: 'shift_days', type: 'number', min: 1, max: 90, step: 1 }], + }); + expect(def.params?.[0]).toMatchObject({ min: 1, max: 90, step: 1 }); + }); + }); + + describe('— a mis-spelled key is an error, not a silent default', () => { + it('names the offending key and the canonical one', () => { + const issues = reject({ name: 'set_labels', opeartion: 'update' }); + expect(issues.join('\n')).toContain('`opeartion` → `operation`'); + }); + + it('catches the aggregate typo that silently costs N requests', () => { + // `excution: 'aggregate'` parsed before #4457, leaving the def in + // per-record mode: the endpoint written for ONE `_selectedIds` call gets + // N calls instead — the exact defect objectui#3139 existed to fix. + const issues = reject({ name: 'recalc_selection', operation: 'custom', excution: 'aggregate' }); + expect(issues.join('\n')).toContain('`excution` → `execution`'); + }); + + it('refuses a hand-written `actionDef` with the reason, not a spelling hint', () => { + const issues = reject({ + name: 'recalc_selection', + operation: 'custom', + execution: 'aggregate', + actionDef: { type: 'api', endpoint: '/whatever' }, + }); + expect(issues.join('\n')).toContain('attached by the renderer, not authored'); + expect(issues.join('\n')).toContain('past the action registry'); + }); + + it('prescribes the view-side replacement for the retired `bulkEnabled`', () => { + const issues = reject({ name: 'mark_done', operation: 'update', bulkEnabled: true }); + expect(issues.join('\n')).toContain('retired in spec 17'); + }); + }); + + describe("— `operation: 'custom'` must say which dispatch it means", () => { + it('rejects a custom def with no execution mode', () => { + // Without `execution: 'aggregate'` the renderer attaches no `actionDef`, + // and the executor's custom branch resolves to `Promise.resolve()` per + // row: N green ticks, zero work (ADR-0078 — reports success, does + // nothing). + const issues = reject({ name: 'generate_qr_zip', operation: 'custom' }); + expect(issues.join('\n')).toContain('treats as a no-op'); + // Both legal forms are named, so the fix does not need the source. + expect(issues.join('\n')).toContain("`bulkActions: ['generate_qr_zip']`"); + expect(issues.join('\n')).toContain("`execution: 'aggregate'`"); + }); + + it("rejects an explicit `execution: 'perRecord'` for the same reason", () => { + // The default spelled out loud is still the inert shape — the per-record + // form is `bulkActions`, not a def. + expect(reject({ name: 'generate_qr_zip', operation: 'custom', execution: 'perRecord' }).join('\n')) + .toContain('treats as a no-op'); + }); + }); + + describe('— keys the executor would never read are refused, not dropped', () => { + it('rejects `execution` on a data-plane operation', () => { + expect(reject({ name: 'set_labels', operation: 'update', execution: 'aggregate' }).join('\n')) + .toContain("only applies to `operation: 'custom'`"); + }); + + it('rejects `patch` outside an update', () => { + expect(reject({ name: 'purge', operation: 'delete', patch: { archived: true } }).join('\n')) + .toContain("only applies to `operation: 'update'`"); + }); + + it('rejects `params` on a delete — a bulk delete takes ids only', () => { + expect(reject({ name: 'purge', operation: 'delete', params: [{ name: 'why', type: 'text' }] }).join('\n')) + .toContain('the executor never reads'); + }); + + it('rejects `batchSize` on an aggregate def and points at `maxRecords`', () => { + const issues = reject({ name: 'gen_zip', operation: 'custom', execution: 'aggregate', batchSize: 25 }); + expect(issues.join('\n')).toContain('ONE call by definition'); + expect(issues.join('\n')).toContain('`maxRecords`'); + }); + + it('keeps `batchSize` legal on the data-plane operations it governs', () => { + expect(ok({ name: 'set_labels', operation: 'update', batchSize: 25 }).batchSize).toBe(25); + expect(ok({ name: 'purge', operation: 'delete', batchSize: 25 }).batchSize).toBe(25); + }); + }); + + describe('— floor', () => { + it('requires `name` and `operation`', () => { + expect(reject({}).length).toBeGreaterThan(0); + expect(reject({ name: 'set_labels' }).join('\n')).toContain('operation'); + expect(reject({ operation: 'update' }).join('\n')).toContain('name'); + }); + + it('holds `name` to the same identifier rule as an action', () => { + // An aggregate def's `name` IS an action name — a different spelling + // rule here would let a def name something no action could be called. + expect(reject({ name: 'Set Labels', operation: 'update' }).length).toBeGreaterThan(0); + }); + + it('accepts a bare `visible` CEL string and the `{ dialect, source }` envelope', () => { + expect(ok({ name: 'purge', operation: 'delete', visible: "'admin' in current_user.positions" }).visible) + .toBeDefined(); + expect(ok({ name: 'purge', operation: 'delete', visible: { dialect: 'cel', source: 'true' } }).visible) + .toBeDefined(); + }); + }); +}); diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts new file mode 100644 index 0000000000..616e09d205 --- /dev/null +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { ExpressionInputSchema } from '../shared/expression.zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { FieldType } from '../data/field.zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// WHY THIS FILE EXISTS (#4457) — engineering rationale; the author-facing +// description is the JSDoc below, which is what the generated reference page +// renders. +// +// Until #4457 `bulkActionDefs` was `z.array(z.record(z.string(), z.any()))` — a +// selection-bar button with NO SHAPE AT ALL. The real contract lived only in +// objectui's `BulkActionDef` interface (`packages/types/src/objectql.ts`) and in +// the executor that reads it, so every authoring mistake landed as a silent +// runtime downgrade instead of a parse error: +// +// - `opeartion: 'update'` → no `operation` at all → the executor's exhaustive +// switch falls through to `Unknown operation: undefined`, PER ROW. +// - `excution: 'aggregate'` → the def stays per-record, so the endpoint +// written for ONE `_selectedIds` call gets N calls instead — the exact +// defect objectui#3139 existed to make expressible. +// - `actionDef: {...}` → a renderer-INTERNAL key (attached by +// `resolveBulkActions` when it resolves a name) authored by hand: it looks +// like it should work, and the executor will dispatch whatever is inside it, +// bypassing the action registry, its permission gate and its param contract. +// +// That is ADR-0018's "second vocabulary" smell (an action surface sharing none +// of `ActionSchema`'s checks) crossed with ADR-0078's silently-inert metadata. +// The def gets the same treatment `ActionParamSchema` got in #3746/#4001: a +// strict shape whose unknown-key error names the offending key and the +// canonical spelling. +// +// THE RENDERER IS THE SOURCE OF TRUTH, AND THIS MIRRORS IT DELIBERATELY. +// Every key exists because objectui reads it, and each one's shape is the shape +// objectui's type declares — including the two places that is narrower or wider +// than the platform default: +// +// - `label` and the param/option labels are `z.string()`, not +// `I18nLabelSchema`. An authored def reaches the grid VERBATIM +// (`app-shell/ObjectView.tsx` passes `bulkActionDefs` straight through; +// `resolveBulkActions` documents that authored defs are "left as-authored"), +// so nothing resolves an `{ en, zh }` map on this path — and the bar renders +// `def.label` as a React child, so blessing the map form would trade a parse +// error for a blank screen. Localizing means declaring a real action and +// naming it in `bulkActions`: THAT path runs through the i18n resolver +// (`toBulkActionDef`'s `localize`). +// - `params[]` is `.passthrough()`. objectui's `BulkActionParam` declares an +// explicit `[key: string]: unknown` catch-all — widget config forwarded to +// the field renderer as-is (min/max/step/format). Locking it down would +// reject valid config, so declared keys are typed and the rest rides +// through, the same call `dashboard.zod.ts` makes for a widget's `config`. +// +// KNOWN DIVERGENCE, DELIBERATELY NOT FIXED HERE. A bulk param and an action +// param are the same idea under different spellings (`help`/`helpText`, +// `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which +// `ActionParamSchema` has no counterpart for). objectui already owns a converter +// for the PROMOTED direction (`toBulkParam` in `resolveBulkActions.ts`); +// converging the AUTHORED direction means teaching the renderer to run authored +// params through it and giving `ActionParamSchema` a `labelField` — a cross-repo +// change with its own migration, not a rider on typing the def. Typing them as +// they are is what makes the divergence visible instead of implied, which is the +// prerequisite for closing it. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Bulk Action Schemas + * + * The vocabulary of a list view's `bulkActionDefs` — one entry per button in + * the multi-select toolbar. Use a def for a mass data-plane mutation that no + * action expresses (`operation: 'update'` with a patch, or `'delete'`), or for + * an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the + * action it NAMES once for the whole selection. + * + * For the per-record dispatch, name the action in the view's + * `bulkActions: ['']` instead — the bare-string form, promoted with the + * action's own label, params and `visible`. + */ + +/** How the executor mutates the selected records. */ +export const BulkActionOperationSchema = z.enum(['update', 'delete', 'custom']); +export type BulkActionOperation = z.infer; + +/** How many dispatches a `custom` def makes for a selection of N records. */ +export const BulkActionExecutionSchema = z.enum(['perRecord', 'aggregate']); +export type BulkActionExecution = z.infer; + +/** + * One input collected ONCE by the bulk dialog before the run (never re-prompted + * per record). For `operation: 'update'` the collected values ARE the patch + * (merged over the def's static `patch`); for an aggregate `custom` def they + * ride along as the action's params. + * + * `.passthrough()` — see the module header: the renderer's own type declares a + * catch-all for widget config, so the declared keys are typed and extras are + * forwarded. That means a typo'd key here still ships silently; the def LEVEL + * is where strictness buys something, and this level is where it would lie. + */ +export const BulkActionParamSchema = lazySchema(() => z.object({ + name: z.string().min(1).describe('Param key — becomes params[name] in the patch / action params bag.'), + label: z.string().optional().describe('Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header).'), + help: z.string().optional().describe('Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.)'), + type: FieldType.describe('Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…).'), + required: z.boolean().optional().describe('Blocks the Confirm button until a value is present.'), + default: z.unknown().optional().describe('Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.)'), + options: z.array(z.object({ + label: z.string().describe('Option label (plain string — not i18n-resolved on this path).'), + value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value.'), + })).optional().describe('Static options for select-style widgets.'), + object: SnakeCaseIdentifierSchema.optional().describe("Target object for a `lookup` widget. (An ActionParam spells this `reference`.)"), + labelField: z.string().optional().describe('Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id).'), + multiple: z.boolean().optional().describe('Allow picking multiple values — the param value becomes an array and is written to the patch as-is.'), + placeholder: z.string().optional().describe('Placeholder text.'), +}).passthrough()); +export type BulkActionParam = z.infer; + +/** Declared keys of a bulk-action def — the "did you mean" pool. */ +const BULK_ACTION_DEF_KEYS = [ + 'name', 'label', 'icon', 'variant', 'operation', 'execution', 'patch', + 'params', 'confirmText', 'confirmLabel', 'visible', 'maxRecords', 'batchSize', +] as const; + +const bulkActionDefUnknownKeyError = strictUnknownKeyError({ + surface: 'this bulk action definition', + knownKeys: BULK_ACTION_DEF_KEYS, + aliases: { + action: 'name', + actionname: 'name', + title: 'label', + op: 'operation', + mode: 'execution', + confirm: 'confirmText', + confirmmessage: 'confirmText', + limit: 'maxRecords', + max: 'maxRecords', + batch: 'batchSize', + }, + guidance: { + // Not a typo — a real key the RENDERER attaches, which is exactly why an + // author reaching for it needs more than "did you mean". + actionDef: + '`actionDef` is attached by the renderer, not authored: `resolveBulkActions` looks the ' + + 'action up by `name` and inlines it. Writing it by hand smuggles an action definition ' + + 'past the action registry — no permission gate, no param contract, no lint. Declare the ' + + 'action normally and let this def name it.', + bulkEnabled: + '`action.bulkEnabled` was retired in spec 17: the selection bar is driven by the LIST ' + + "VIEW's `bulkActions` / `bulkActionDefs`, which is this array. There is nothing to set.", + recordIdParam: + '`recordIdParam` belongs on the ACTION, not on the def that names it — a per-record bulk ' + + "run reuses the action's own declaration, and an `execution: 'aggregate'` run carries " + + 'the whole selection in `params._selectedIds` instead of a single record id.', + }, + history: + 'Until #4457 the whole array was `z.array(z.record(z.string(), z.any()))` — every key parsed, ' + + 'so a mis-spelled one shipped as a button that silently ran the DEFAULT behaviour (or none ' + + 'at all).', +}); + +/** + * Rich, schema-driven definition of one button in the multi-select bar. + * + * Two vocabularies reach that bar and they are not interchangeable: + * + * - **`bulkActions: ['']`** — names an action the object declares. + * The renderer promotes it to a def carrying the action's label, icon, + * `visible`, confirm text and params, and dispatches it ONCE PER selected + * record. This is the right form for "run this action on each of them". + * - **`bulkActionDefs: [{...}]`** (this schema) — a def authored in the view. + * Use it for a mass data-plane mutation (`update` / `delete`) that no action + * expresses, or for an `execution: 'aggregate'` custom action that must see + * the whole selection in ONE call. + * + * The refinements below reject the combinations the executor cannot honour. + * Each one parsed before #4457 and produced a button that reports success while + * doing nothing, or a key the executor silently drops (ADR-0078) — failure + * modes invisible from the authoring side, which is why they are caught here + * rather than written down and hoped for. + */ +export const BulkActionDefSchema = lazySchema(() => z.object({ + name: SnakeCaseIdentifierSchema.describe('Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch.'), + label: z.string().optional().describe('Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in `bulkActions` to get localization).'), + icon: z.string().optional().describe('Lucide icon name (e.g. "user-check", "trash-2").'), + variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'outline']).optional().describe('Visual treatment of the button.'), + operation: BulkActionOperationSchema.describe("What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`)."), + execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), + patch: z.record(z.string(), z.unknown()).optional().describe("For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog."), + params: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), + confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'), + confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), + visible: ExpressionInputSchema.optional().describe('Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), + maxRecords: z.number().int().positive().optional().describe('Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request.'), + batchSize: z.number().int().positive().optional().describe('Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition.'), +}, { error: bulkActionDefUnknownKeyError }).strict() + .superRefine((def, ctx) => { + // ── `custom` without `aggregate` is the historical no-op ────────────── + // `useBulkExecutor`'s custom branch dispatches only when the def carries a + // renderer-attached `actionDef`, and `resolveBulkActions` attaches one for + // exactly ONE authored shape: `execution: 'aggregate'`. Every other custom + // def resolves to `Promise.resolve()` per row — N green ticks, zero work. + if (def.operation === 'custom' && def.execution !== 'aggregate') { + ctx.addIssue({ + code: 'custom', + path: ['execution'], + message: + `Bulk action "${def.name}" declares \`operation: 'custom'\` without ` + + `\`execution: 'aggregate'\`, which the renderer treats as a no-op — the button runs, ` + + `reports success for every selected record, and does nothing. Pick the form you meant: ` + + `to run the action ONCE PER record, drop this def and name the action in the view's ` + + `\`bulkActions: ['${def.name}']\` (it is promoted with the action's label, params and ` + + `\`visible\`); to run it ONCE for the whole selection, add \`execution: 'aggregate'\` ` + + `(the handler reads \`params._selectedIds\`). For a mass field update or delete, use ` + + `\`operation: 'update'\` / \`'delete'\` instead.`, + }); + } + + // ── The rest: keys that parse but the executor never reads ──────────── + if (def.execution !== undefined && def.operation !== 'custom') { + ctx.addIssue({ + code: 'custom', + path: ['execution'], + message: + `\`execution\` only applies to \`operation: 'custom'\` — a '${def.operation}' def is a ` + + `data-plane mass mutation, always batched per record. Remove it, or switch the def to ` + + `\`operation: 'custom'\` if you meant to dispatch an action.`, + }); + } + if (def.patch !== undefined && def.operation !== 'update') { + ctx.addIssue({ + code: 'custom', + path: ['patch'], + message: + `\`patch\` only applies to \`operation: 'update'\` — a '${def.operation}' def never ` + + `writes fields, so these values are silently dropped. Use \`operation: 'update'\`, or ` + + `move the constant into the action's own \`bodyExtra\`/\`params\` if this is a custom def.`, + }); + } + if (def.params !== undefined && def.operation === 'delete') { + ctx.addIssue({ + code: 'custom', + path: ['params'], + message: + `\`params\` on a \`delete\` def collects values the executor never reads — a bulk delete ` + + `takes ids only. Remove them, or use \`operation: 'update'\` if the dialog is meant to ` + + `write something.`, + }); + } + if (def.batchSize !== undefined && def.execution === 'aggregate') { + ctx.addIssue({ + code: 'custom', + path: ['batchSize'], + message: + `\`batchSize\` does not apply to an aggregate def — the whole selection goes out in ONE ` + + `call by definition, which is the point of \`execution: 'aggregate'\`. To bound how ` + + `much a single call may carry, use \`maxRecords\`.`, + }); + } + })); +export type BulkActionDef = z.infer; diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index cb3b3052c5..95a63578c7 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -15,6 +15,7 @@ export * from './chart-aggregate'; export * from './i18n.zod'; export * from './responsive.zod'; export * from './app.zod'; +export * from './bulk-action.zod'; export * from './view.zod'; export * from './dashboard.zod'; export * from './report.zod'; diff --git a/packages/spec/src/ui/react-blocks.test.ts b/packages/spec/src/ui/react-blocks.test.ts index 12255f11a1..9cdfe14c06 100644 --- a/packages/spec/src/ui/react-blocks.test.ts +++ b/packages/spec/src/ui/react-blocks.test.ts @@ -7,7 +7,15 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { REACT_BLOCKS, REACT_OVERLAY_SHADOWS } from './react-blocks'; +import { + REACT_BLOCKS, + REACT_OVERLAY_SHADOWS, + REACT_RECORD_BLOCK_ALTERNATIVES, + RECORD_CONTEXT_BLOCK_TAGS, + isRecordContextBlockType, + reactBlockTagFor, +} from './react-blocks'; +import { ComponentPropsMap } from './component.zod'; /** The prop names a block's spec schema declares, as the contract generator reads them. */ function schemaPropNames(schema: unknown): string[] { @@ -64,26 +72,60 @@ describe('REACT_BLOCKS — overlay/schema seam', () => { for (const tag of Object.keys(REACT_OVERLAY_SHADOWS)) expect(tags.has(tag)).toBe(true); }); - /** - * The `record:related_list` reading, pinned where an author and the linter - * both read it. `validate-page-field-bindings` resolves this component's - * `columns`/`sort`/`filter` against `properties.objectName` as the RELATED - * object on the metadata surface, and `validate-react-page-props` now does - * the same on the react surface — both are wrong the moment this description - * says "parent" again. - */ - it(' is published as the related (child) object', () => { - const block = REACT_BLOCKS.find((b) => b.tag === 'RecordRelatedList'); - const objectName = block?.interactions.find((i) => i.name === 'objectName'); - expect(objectName).toBeDefined(); - expect(objectName!.description).toMatch(/RELATED \(child\) object/); - expect(objectName!.description).toMatch(/NOT the parent/); - // The spec schema is the authority it must agree with. - expect(schemaPropNames(block!.schema)).toContain('objectName'); - }); - it('every block tag is unique (the contract is keyed by it)', () => { const tags = REACT_BLOCKS.map((b) => b.tag); expect(new Set(tags).size).toBe(tags.length); }); }); + +/** + * #4413. Four `record:*` blocks were published here with `objectName` / + * `recordId` overlay props that NO renderer reads: every one of them takes its + * record from the context a record page mounts, and a `kind:'react'` page + * mounts none — so a page authored exactly to contract rendered empty, silently. + * + * The index is the authority the publish gate reads, so the exclusion has to + * hold HERE or the gate rejects what the contract still advertises (or, worse, + * stops rejecting what came back). + */ +describe('REACT_BLOCKS — the record:* family is out (#4413)', () => { + it('publishes no block that needs a record context', () => { + const offenders = REACT_BLOCKS.filter((b) => isRecordContextBlockType(b.schemaType)); + expect(offenders.map((b) => b.tag)).toEqual([]); + }); + + it('covers every record:* type the spec declares, under the tag the react scope injects', () => { + const specTypes = Object.keys(ComponentPropsMap).filter(isRecordContextBlockType); + // Not a hand-kept list: a record component added to ComponentPropsMap is + // gated the day it lands, under the tag objectui's `toPascal` gives it. + expect(specTypes.length).toBeGreaterThan(0); + expect([...RECORD_CONTEXT_BLOCK_TAGS.entries()].sort()).toEqual( + specTypes.map((t) => [reactBlockTagFor(t), t]).sort(), + ); + // The four that were published, spelled out — the regression this pins. + for (const tag of ['RecordDetails', 'RecordHighlights', 'RecordRelatedList', 'RecordPath']) { + expect(RECORD_CONTEXT_BLOCK_TAGS.has(tag)).toBe(true); + } + }); + + it('classifies by the `record:` prefix, not by a tag spelling', () => { + expect(isRecordContextBlockType('record:related_list')).toBe(true); + expect(isRecordContextBlockType('record:activity')).toBe(true); + expect(isRecordContextBlockType('list-view')).toBe(false); + expect(isRecordContextBlockType('element:record_picker')).toBe(false); + }); + + it('names a working replacement for each withdrawn block', () => { + // The gate quotes these; an empty one would leave an author with a refusal + // and no way forward. + for (const type of ['record:details', 'record:highlights', 'record:related_list', 'record:path']) { + expect(REACT_RECORD_BLOCK_ALTERNATIVES[type]).toBeTruthy(); + } + // Each names a block the react tier actually publishes. + const tags = REACT_BLOCKS.map((b) => b.tag); + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']).toContain('ListView'); + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:details']).toContain('ObjectForm'); + expect(tags).toContain('ListView'); + expect(tags).toContain('ObjectForm'); + }); +}); diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index e889cda3d7..24a7e8f00e 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -14,12 +14,7 @@ import type { ZodTypeAny } from 'zod'; import { ListViewSchema, FormViewSchema } from './view.zod'; -import { - RecordDetailsProps, - RecordRelatedListProps, - RecordHighlightsProps, - RecordPathProps, -} from './component.zod'; +import { ComponentPropsMap } from './component.zod'; import { ChartConfigSchema } from './chart.zod'; export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback'; @@ -69,11 +64,94 @@ export const REACT_OVERLAY_SHADOWS: Readonly> // the React-side rule that pairs it with `onRowClick` — neither of which the // declarative schema has anywhere to say. ListView: ['navigation'], - // Same prop, same meaning as the schema's "Related object name". Kept in the - // overlay because it IS this block's data binding (`kind: 'binding'`, and - // required, as the schema declares it) and because the trap #4340 found is - // worth spelling out where an author reads it. - RecordRelatedList: ['objectName'], +}; + +/** + * The `record:*` family is NOT part of the react tier, and never was in the + * runtime (#4413). This is the ledger of that exclusion — the reason, and what + * an author writes instead. + * + * ## Why they are out + * + * These blocks are RECORD-PAGE COMPOSITION blocks, not data blocks. Every one + * of them reads its record from the shared record context a record page mounts + * once (`RecordDetailView` fetches the record; N blocks render it), and they + * are coupled THROUGH that context: `record:details` drops the fields a + * mounted `record:highlights` registered, and the record-level inline-edit save + * bar commits one draft for all of them under a single `ifMatch` version. + * + * A `kind:'react'` page mounts no such context, so `useRecordContext()` returns + * null and each block renders its "bind a record to preview" placeholder — or, + * for `record:related_list` (the one that reads `schema.objectName`), a list + * that refuses to fetch because the parent id never arrives. + * + * The react tier nonetheless published `objectName` / `recordId` overlay props + * on four of them, which no renderer reads. That contract was not merely + * unimplemented, it was the wrong SHAPE: per-block bindings describe four + * independent fetches of one record, which is precisely the coupling the shared + * context exists to prevent. Implementing it would have fossilized that + * (ADR-0082 D1 / Prime Directive #12), so the props were withdrawn instead — + * ADR-0080 "capability ≠ contract". + * + * The react tier already has blocks that DO bind by their own props + * (`` reads `schema.objectName`/`schema.recordId`, `` + * reads `schema.objectName` + `filters`), and on a react page the parent record + * is ordinary React state — so the scenarios these blocks served are expressible + * without a context to fake. {@link REACT_RECORD_BLOCK_ALTERNATIVES} is what the + * lint tells an author who reaches for one. + * + * If the family is ever wanted here, the shape is a record SCOPE provider block + * an author wraps around them (one fetch, shared context) — not per-block props. + */ +export const RECORD_CONTEXT_TYPE_PREFIX = 'record:'; + +/** Whether a registry component type needs the record page's record context. */ +export function isRecordContextBlockType(type: string): boolean { + return type.startsWith(RECORD_CONTEXT_TYPE_PREFIX); +} + +/** + * PascalCase tag the react scope injects for a registry type — objectui's + * `toPascal` in `renderers/layout/react-page.tsx`, which builds the scope from + * EVERY public non-container block. Restated here because that is what makes a + * withdrawn block still reachable as ``, and so what the + * publish gate has to recognise. + */ +export function reactBlockTagFor(schemaType: string): string { + return schemaType + .split(/[-_:]/) + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +/** + * Every `record:*` type the spec declares, keyed by the tag the react scope + * injects for it. Derived from `ComponentPropsMap` rather than restated, so a + * record component added to the spec is covered by the publish gate the day it + * lands. + */ +export const RECORD_CONTEXT_BLOCK_TAGS: ReadonlyMap = new Map( + Object.keys(ComponentPropsMap) + .filter(isRecordContextBlockType) + .map((type) => [reactBlockTagFor(type), type]), +); + +/** + * What to write instead, per withdrawn block. Read by the publish gate + * (`react-block-needs-record-context`) so the error names the working prop-bound + * block rather than only the broken one. Types without an entry fall back to the + * generic advice: author the page as `type:'record'`. + */ +export const REACT_RECORD_BLOCK_ALTERNATIVES: Readonly> = { + 'record:details': + ' — it binds by its own props.', + 'record:highlights': + ', or read the record with useAdapter().findOne and lay the strip out in JSX.', + 'record:related_list': + '\', \'=\', parentId]} columns={[…]} /> — the parent binding is an ordinary filter on a react page.', + 'record:path': + 'read the record with useAdapter().findOne and render the stage bar in JSX (layout is this tier\'s job).', }; export interface ReactBlockDef { @@ -168,57 +246,15 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ { name: 'data', type: 'any[]', kind: 'binding', description: 'Static/precomputed data to chart directly instead of binding via objectName + aggregate.' }, ], }, - { - tag: 'RecordDetails', - schemaType: 'record:details', - summary: 'Field-detail panel for the bound record. Config props from the spec RecordDetails schema.', - schema: RecordDetailsProps, - interactions: [ - { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to show.' }, - { name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' }, - ], - }, - { - tag: 'RecordHighlights', - schemaType: 'record:highlights', - summary: 'Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema.', - schema: RecordHighlightsProps, - interactions: [ - { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to summarize.' }, - { name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' }, - ], - }, - { - tag: 'RecordRelatedList', - schemaType: 'record:related_list', - summary: - 'Related child records via a lookup. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema.', - schema: RecordRelatedListProps, - interactions: [ - { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The parent record whose children are listed.' }, - // Restates RecordRelatedListProps.objectName — ledgered in - // REACT_OVERLAY_SHADOWS. This block is the ONE whose spec schema already - // declares `objectName`, and it means the CHILD object; the overlay used - // to gloss it as the parent, which is the contract conflict #4340 opened - // on. Same meaning as the schema now, said in the words that keep an - // author from writing the parent here. - { name: 'objectName', type: 'string', kind: 'binding', required: true, description: 'The RELATED (child) object whose records this list renders — e.g. objectName="invoice" on an account page. NOT the parent object: the parent record is bound by recordId.' }, - ], - }, - { - tag: 'RecordPath', - schemaType: 'record:path', - summary: 'Stage/progress bar driven by a status field. Config props from the spec RecordPath schema.', - schema: RecordPathProps, - interactions: [ - { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record whose stage to show.' }, - { name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' }, - ], - }, + // NOTE: `` / `` / `` / + // `` were published here until #4413 and are deliberately gone — + // no renderer read the `objectName`/`recordId` this index declared for them. + // See REACT_RECORD_BLOCK_ALTERNATIVES above for the ledger and the blocks + // that replace them; the publish gate rejects them on a react page. { tag: 'Block', schemaType: '(any)', - summary: 'Escape hatch — render any registered component by type. etc.', + summary: 'Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page\'s record context and are rejected here too (#4413).', interactions: [ { name: 'type', type: 'string', kind: 'binding', required: true, description: 'The registered component type to render.' }, ], diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index b4a5efa348..0a50209e4a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -11,6 +11,7 @@ import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; import { retiredKey } from '../shared/retired-key'; import { FieldType, SelectOptionSchema } from '../data/field.zod'; +import { BulkActionDefSchema } from './bulk-action.zod'; /** * HTTP Method Enum & HTTP Request Schema @@ -737,7 +738,21 @@ export const ListViewSchema = lazySchema(() => z.object({ /** Row & Bulk Actions */ rowActions: z.array(z.string()).optional().describe('Actions available for individual row items'), bulkActions: z.array(z.string()).optional().describe('Actions available when multiple rows are selected'), - bulkActionDefs: z.array(z.record(z.string(), z.any())).optional().describe('Rich bulk action definitions (schema-driven, executed via BulkActionDialog)'), + bulkActionDefs: z.array(BulkActionDefSchema).optional().describe( + 'Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a ' + + "mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for " + + "an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the " + + 'action it NAMES once for the whole selection — the renderer injects `params._selectedIds: ' + + 'string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate ' + + 'artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a ' + + 'handler that cannot cover the whole selection must reject, and per-row retry is replaced by ' + + 're-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` ' + + "on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` " + + 'instead — the bare-string form, promoted with the action\'s own label, params and `visible`; a ' + + "'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time " + + '(#4457). Toolbar url/api actions can also interpolate the current selection via ' + + '`${ctx.selection.ids}` / `${ctx.selection.count}`.', + ), /** Performance */ virtualScroll: z.boolean().optional().describe('Enable virtual scrolling for large datasets'), diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 1a615da6ab..8f41dafbf1 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -108,8 +108,31 @@ export interface BootOptions { * nodes. Without this the dispatcher's automation routes resolve no `automation` * service and flow execution is unreachable. Opt-in (like `multiTenant`) so the * default boot stays lean for apps that don't exercise flows. Default `false`. + * + * Boots the plugin's OWN default (`suspendedRunStore: 'auto'` — persist to + * `sys_automation_run` when an ObjectQL engine is present), so this layer + * exercises the same assembly a real deployment gets. It used to hardcode + * `'memory'`, which made the durable path **structurally unreachable** from + * every dogfood/e2e fixture (#4470): engine-side persistence was unit-tested + * against a fake table and the approval chain was e2e-tested wholly in + * memory, while the ASSEMBLY between them — is the object registered, is the + * table created, is the store actually attached — was covered by nothing. + * #4420 grew in exactly that gap. + * + * Pass `{ suspendedRunStore: 'memory' }` to opt a fixture back out. */ - automation?: boolean; + automation?: boolean | { suspendedRunStore?: 'auto' | 'memory' }; + /** + * Back the in-process SQLite database with a FILE instead of `:memory:`. + * + * The default in-memory database dies with the kernel, which makes one + * question unaskable in this harness: does state written by one process + * survive into the next? Point two sequential `bootStack` calls at the same + * path and the second is a genuine COLD BOOT over the first's data — the + * restart a durable suspended run has to survive (ADR-0019). Callers own the + * file's lifetime (create it under a temp dir, delete it after). + */ + databaseFile?: string; /** * Extra plugins to register between the app/service pairs and the * SecurityPlugin — the slot where `objectstack dev` auto-loads optional @@ -163,7 +186,12 @@ export async function bootStack( // §Risk mitigation the ADR promised), not the legacy pre-built DriverPlugin // escape hatch. await kernel.use(new ObjectQLPlugin()); - await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new DefaultDatasourcePlugin({ + driver: 'sqlite-wasm', + // `opts.databaseFile` makes the database outlive the kernel, so a second + // boot over the same path is a real cold start (see BootOptions.databaseFile). + config: { filename: opts.databaseFile ?? ':memory:' }, + })); // HTTP server (registers the `http-server` IHttpServer service the REST + // dispatcher plugins mount their routes onto). Port 0 = ephemeral; we never @@ -248,11 +276,21 @@ export async function bootStack( // Automation service — opt-in. Registered before bootstrap so its start() // phase pulls the app's flows from the ObjectQL registry (populated by - // AppPlugin.init) and registers them. `memory` suspended-run store keeps the - // harness free of any manifest/persistence dependency for flow execution. + // AppPlugin.init) and registers them. + // + // #4470: this used to pin `suspendedRunStore: 'memory'`, which meant no + // dogfood/e2e fixture could reach the DB-backed suspended-run store even in + // principle — the ASSEMBLY (object registered? table created? store actually + // attached?) was the one layer neither the engine unit tests nor the + // approval e2e covered, and #4420 grew there. It now boots the plugin's own + // `'auto'` default, the same wiring `objectstack dev`/`serve` get, and a + // fixture that wants the old behaviour asks for it explicitly. if (opts.automation) { const { AutomationServicePlugin } = await import('@objectstack/service-automation'); - await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + const automationOpts = typeof opts.automation === 'object' ? opts.automation : {}; + await kernel.use(new AutomationServicePlugin({ + ...(automationOpts.suspendedRunStore ? { suspendedRunStore: automationOpts.suspendedRunStore } : {}), + })); } // Caller-supplied optional service pairs (see BootOptions.extraPlugins). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c7903a29f..b7f6b44014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,6 +402,9 @@ importers: '@objectstack/metadata': specifier: workspace:* version: link:../metadata + '@objectstack/metadata-protocol': + specifier: workspace:* + version: link:../metadata-protocol '@objectstack/objectql': specifier: workspace:^ version: link:../objectql diff --git a/scripts/build-console.sh b/scripts/build-console.sh index f185f616c7..0c5f9e4bf1 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -185,10 +185,10 @@ echo "✓ Bundle canary '${BUNDLE_CANARY}' present — framework client is in th BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}" -# ADR-0080/0081: the public-tier SDUI manifest and the spec↔frontend react-block -# conformance ratchet are intentionally NOT generated here — they require a real -# browser (Playwright) to enumerate the console registry, and the console build -# must not drag in a browser dependency. Regenerate them on demand instead: +# ADR-0080/0081: the public-tier SDUI manifest and the spec↔registry react-block +# declaration-parity ratchet are intentionally NOT generated here — they require a +# real browser (Playwright) to enumerate the console registry, and the console +# build must not drag in a browser dependency. Regenerate them on demand instead: # pnpm sdui:manifest (see scripts/gen-sdui-manifest.sh) -echo "ℹ SDUI manifest + conformance ratchet are decoupled from the console build." +echo "ℹ SDUI manifest + declaration-parity ratchet are decoupled from the console build." echo " Run 'pnpm sdui:manifest' on demand to regenerate (requires Playwright)." diff --git a/scripts/check-error-code-casing.mjs b/scripts/check-error-code-casing.mjs index fde29695bd..69f5a9623e 100644 --- a/scripts/check-error-code-casing.mjs +++ b/scripts/check-error-code-casing.mjs @@ -69,6 +69,7 @@ const EXEMPT_FILES = new Map([ ['packages/rest/src/import-runner.ts', 'D6 field-level import row codes'], ['packages/plugins/plugin-sharing/src/rule-criteria.ts', 'D6 field-level; top-level code is VALIDATION_FAILED'], ['packages/spec/src/ui/action-params.zod.ts', 'D6/ADR-0114 param-addressed issues'], + ['packages/services/service-automation/src/screen-input-contract.ts', 'D6/ADR-0114 screen-field-addressed issues; the refusal code is INVALID_SCREEN_INPUT'], // D6b — persisted audit column ['packages/metadata-core/src/objects/sys-metadata-audit.object.ts', 'D6b persisted audit vocabulary'], ['packages/spec/src/api/errors.test.ts', 'D6 FieldError tests spell field-level codes'], diff --git a/scripts/gen-sdui-manifest.sh b/scripts/gen-sdui-manifest.sh index f9661f31d9..15ce9857cd 100755 --- a/scripts/gen-sdui-manifest.sh +++ b/scripts/gen-sdui-manifest.sh @@ -67,13 +67,22 @@ else fi popd > /dev/null -# ADR-0081: ratchet the spec↔frontend react-block conformance against the -# committed baseline. Warn-only here — run check:react-conformance --strict to -# gate intentionally. -if [[ -f "${FRAMEWORK_ROOT}/packages/spec/react-conformance.baseline.json" ]]; then - echo "→ Ratcheting spec↔frontend react-block conformance (ADR-0081)..." +# ADR-0081/0082: ratchet the spec↔registry react-block DECLARATION PARITY against +# the committed baseline. +# +# `--strict`, i.e. this now GATES. It used to run without it and swallow the exit +# code behind a `⚠`, so "divergence recorded" and "divergence stopped" were two +# very different things wearing the same green build (#4472, secondary finding 1). +# The ratchet only fires on divergence NEW since the accepted baseline, so a +# failure here is a deliberate registry change that needs either a spec/overlay +# edit or an explicit `--update` to accept — never pre-existing noise. +# +# Scope, since a green line here is easy to over-read: this compares two +# DECLARATIONS (spec zod props vs registry-declared inputs) and inspects no +# renderer. See the header of check-react-blocks-declaration-parity.ts. +if [[ -f "${FRAMEWORK_ROOT}/packages/spec/react-declaration-parity.baseline.json" ]]; then + echo "→ Ratcheting spec↔registry react-block declaration parity (ADR-0082)..." ( cd "${FRAMEWORK_ROOT}" && MANIFEST="${TARGET}/sdui.manifest.json" \ - pnpm --filter @objectstack/spec check:react-conformance \ - --baseline react-conformance.baseline.json ) || \ - echo "⚠ conformance ratchet reported new divergence — run check:react-conformance --strict to gate." + pnpm --filter @objectstack/spec check:react-declaration-parity \ + --baseline react-declaration-parity.baseline.json --strict ) fi diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 4a6ee9e498..6f0cfbaa2c 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,6 +1,6 @@ { "examples/app-crm/objectstack.config.ts": 89, - "examples/app-showcase/objectstack.config.ts": 456, + "examples/app-showcase/objectstack.config.ts": 452, "examples/app-todo/objectstack.config.ts": 120, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, diff --git a/scripts/partition-test-shards.mjs b/scripts/partition-test-shards.mjs new file mode 100644 index 0000000000..7619ffcc37 --- /dev/null +++ b/scripts/partition-test-shards.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// partition-test-shards -- deterministic, load-balanced split of a `turbo ls` +// package list across the Test Core shard matrix (ci.yml). +// +// Test Core shards BY PACKAGE, not by vitest --shard passthrough, on purpose. +// The dogfood job's file-level sharding works because dogfood is ONE package +// with ~60 test files; applied across the whole workspace it breaks on every +// package with fewer test files than the shard count. Verified on vitest +// 4.1.10 with a 1-file package: `--shard=1/2` AND `--shard=2/2` both fail with +// "--shard must be a smaller than count of test files" -- and adding +// `--passWithNoTests` converts that error into exit 0 with NO files run on +// EITHER shard. Three workspace packages have exactly one test file today, so +// the passthrough route is a silent-coverage-loss machine, not an option. +// +// Each package's weight is its test-file count. That is a deliberate proxy: +// suite wall-clock is dominated by fixed per-file cost (module-graph +// re-execution per file under isolation -- same measurement objectui's CI +// documents), so file count tracks duration far better than package count. +// Packages are placed heaviest-first into the lightest bin (LPT greedy), with +// all ties broken by name, so every shard computes the identical split from +// the same input without coordinating. +// +// Usage: +// node scripts/partition-test-shards.mjs --shard N/M \ +// [--exclude ]... +// node scripts/partition-test-shards.mjs --self-test +// +// is the output of `turbo ls [--affected] --output=json` +// (shape: {packages:{items:[{name,path}]}}; `turbo ls` is marked experimental, +// so the shape is asserted loudly below rather than defaulted around). +// Prints the selected shard's package names, one per line -- possibly zero +// lines, which the caller must treat as "nothing to run", NOT as "no filter": +// a `turbo run test` with no --filter args runs the entire workspace. + +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const TEST_FILE = /\.test\.[cm]?[jt]sx?$/; +const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.next']); + +function countTestFiles(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return 0; // package path missing locally -- weight 0, still assigned + } + let n = 0; + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) n += countTestFiles(path.join(dir, e.name)); + } else if (TEST_FILE.test(e.name)) { + n++; + } + } + return n; +} + +// LPT greedy: heaviest package into the currently lightest bin. Deterministic: +// input order never matters because both the package sort and the bin choice +// break ties explicitly (by name / by lowest bin index). +export function partition(items, shardCount) { + const sorted = [...items].sort( + (a, b) => b.weight - a.weight || a.name.localeCompare(b.name, 'en') + ); + const bins = Array.from({ length: shardCount }, () => ({ total: 0, names: [] })); + for (const it of sorted) { + let best = 0; + for (let i = 1; i < bins.length; i++) { + if (bins[i].total < bins[best].total) best = i; + } + bins[best].names.push(it.name); + bins[best].total += it.weight; + } + return bins; +} + +function selfTest() { + const mk = (name, weight) => ({ name, weight }); + // Coverage + determinism: every package lands in exactly one bin, and two + // runs over differently-ordered input agree. + const items = [mk('e', 1), mk('a', 9), mk('c', 4), mk('b', 9), mk('d', 3)]; + const shuffled = [items[2], items[4], items[0], items[3], items[1]]; + const a = partition(items, 2); + const b = partition(shuffled, 2); + const flatA = a.flatMap((bin) => bin.names).sort(); + if (flatA.join() !== 'a,b,c,d,e') throw new Error(`coverage: got ${flatA.join()}`); + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error('determinism: input order changed the split'); + // LPT balance bound: bin spread never exceeds the heaviest single weight. + const totals = a.map((bin) => bin.total); + if (Math.max(...totals) - Math.min(...totals) > 9) throw new Error(`balance: totals ${totals}`); + // The two 9s must not share a bin. + const binOfA = a.findIndex((bin) => bin.names.includes('a')); + const binOfB = a.findIndex((bin) => bin.names.includes('b')); + if (binOfA === binOfB) throw new Error('balance: both heaviest packages in one bin'); + // Degenerate inputs: empty list, more shards than packages. + const empty = partition([], 2); + if (empty.some((bin) => bin.names.length > 0)) throw new Error('empty input produced packages'); + const sparse = partition([mk('only', 5)], 3); + if (sparse.flatMap((bin) => bin.names).join() !== 'only') throw new Error('sparse input lost the package'); + console.log('partition-test-shards: self-test OK'); +} + +function main() { + const argv = process.argv.slice(2); + if (argv.includes('--self-test')) { + selfTest(); + return; + } + + let listPath = null; + let shardSpec = null; + const excluded = new Set(); + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--shard') shardSpec = argv[++i]; + else if (arg === '--exclude') excluded.add(argv[++i]); + else if (!arg.startsWith('--') && listPath === null) listPath = arg; + else throw new Error(`unrecognized argument: ${arg}`); + } + const shardMatch = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shardSpec ?? ''); + if (!listPath || !shardMatch) { + console.error('usage: partition-test-shards.mjs --shard N/M [--exclude ]...'); + process.exit(1); + } + const shardIndex = Number(shardMatch[1]); + const shardCount = Number(shardMatch[2]); + if (shardIndex > shardCount) throw new Error(`--shard ${shardSpec}: index exceeds count`); + + const parsed = JSON.parse(readFileSync(listPath, 'utf8')); + const items = parsed?.packages?.items; + if (!Array.isArray(items)) { + throw new Error( + `${listPath}: expected \`turbo ls --output=json\` shape {packages:{items:[...]}} -- ` + + 'did an experimental-command upgrade change the output?' + ); + } + const weighted = []; + for (const it of items) { + if (typeof it?.name !== 'string' || typeof it?.path !== 'string') { + throw new Error(`${listPath}: package entry missing name/path: ${JSON.stringify(it)}`); + } + if (excluded.has(it.name)) continue; + weighted.push({ name: it.name, weight: countTestFiles(it.path) }); + } + const bins = partition(weighted, shardCount); + const mine = bins[shardIndex - 1]; + console.error( + `shard ${shardSpec}: ${mine.names.length}/${weighted.length} packages, ` + + `weight ${mine.total} (all bins: ${bins.map((b) => b.total).join('/')})` + ); + for (const name of mine.names) console.log(name); +} + +main(); diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 868fbbd8b2..7c0c99be80 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -96,7 +96,7 @@ plugins register more via `registerNodeExecutor`, e.g. `approval` below): | `http` | Call an external HTTP API — canonical since protocol 11.0; `http_request` survives only as a deprecation-window alias | | `notify` | Send a notification through the messaging service (inbox channel by default) | | `connector_action` | Invoke a pre-built integration connector | -| `script` | Dispatch to a **registered** callable — `config.actionType` (`email`/`slack`) or a registered `config.function`. Inline `config.script` JS is **not** executed (see pitfall 9) | +| `script` | Call a **registered** function named by `config.function` (see pitfall 9) | | `screen` | Display a UI form to the user (screen flows only) | #### Human Decision @@ -854,12 +854,20 @@ them right the first time: fires once, idempotent, no guard field. For "days remaining" in the message, `daysBetween(today(), record.end_date)`. -9. **`script` nodes must name a callable.** Set `config.actionType` to a built-in - side-effect (`email` / `slack`) **or** `config.function` to a function - registered via `defineStack({ functions: { my_fn: (ctx) => … } })`. An empty - `script` node — or one pointing at an unregistered function — fails loudly. - Inline `config.script` JS is **not executed** by the built-in runtime (no - server-side sandbox) — move logic into a registered `function`. +9. **`script` nodes call a registered function — that is all they do.** Set + `config.function` to a function registered via + `defineStack({ functions: { my_fn: (ctx) => … } })`. It is **required**: an + empty `script` node refuses at execute, and one pointing at an unregistered + function fails loudly. + + The other dispatch forms were retired in spec 17 (#4343) because none of them + ran: `config.actionType: 'email' | 'slack'` were logger-backed stubs that + delivered nothing (with `config.template` / `.recipients` / `.variables` + feeding a message no channel sent), and inline `config.script` JS was never + executed (no server-side sandbox). Use a **`notify`** node for real + notification delivery, a **`connector_action`** (Slack connector) or `http` + webhook for Slack, and a registered function for logic. Stored flows convert + with `os migrate meta --from 16`. **A flow `function` is a PURE compute step — it does NOT read/write the database.** It receives `ctx.input` and **returns** a value; `config.outputVariable` diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 5e85df2ae6..51feaf5ee7 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -15,7 +15,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/automation/node-executor.zod.ts` — Node Executor Plugin Protocol — Wait Node Pause/Resume - `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol - `node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts` — Time-Relative Trigger Protocol -- `node_modules/@objectstack/spec/src/automation/trigger-registry.zod.ts` — Trigger Registry Protocol - `node_modules/@objectstack/spec/src/automation/webhook.zod.ts` — Webhook Trigger Event - `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 4594076b10..a2e76fe975 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -19,6 +19,13 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes (#4410). +- `node_modules/@objectstack/spec/src/data/driver/config-registry.zod.ts` — The driver-id → `datasource.config` shape registry (#4410). +- `node_modules/@objectstack/spec/src/data/driver/memory.zod.ts` — Memory Driver Configuration Schema +- `node_modules/@objectstack/spec/src/data/driver/mongo.zod.ts` — MongoDB Standard Driver Protocol +- `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` +- `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) @@ -29,6 +36,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities - `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema +- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas - `node_modules/@objectstack/spec/src/ui/chart.zod.ts` — Unified Chart Type Taxonomy - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 229cbb731c..07958757bb 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -324,7 +324,7 @@ org / user / transaction. Methods: | Method | Capability | Call | |:--|:--|:--| | `find(opts)` | `api.read` | `find({ where: { … }, fields, sort, limit })` → array | -| `findOne(opts)` | `api.read` | `findOne({ where: { id } })` → record \| `null` | +| `findOne(opts)` | `api.read` | `findOne({ where: { id } })` → record \| `null` — needs a predicate or an `orderBy`, see below | | `count(opts)` | `api.read` | `count({ where: { … } })` → number | | `insert(data)` | `api.write` | `insert({ … })` | | `update(data, opts?)` | `api.write` | **`update({ id, ...fields })`** — put the id **inside** `data` | @@ -346,6 +346,25 @@ await ctx.api.object('task').find({ where: { $and: [{ done: false }, { owner: ui > `[['id', '=', x]]` — that is not a supported value shape and silently matches > nothing. +**`findOne` must say which record it wants.** It reads a single row, so an +absent or empty predicate does not come back as `null` — it comes back as the +object's **first row**: a real, plausible-looking record with nothing to do with +what you asked for, which your `if (!row)` cannot catch. So `findOne()`, +`findOne({})` and `findOne({ where: {} })` **throw** (#4419). Be specific in one +of three ways: + +```js +await ctx.api.object('candidate').findOne({ where: { id } }); // by predicate +await ctx.api.object('candidate').findOne({ search: 'Acme' }); // by search +await ctx.api.object('audit') + .findOne({ orderBy: [{ field: 'created_at', order: 'desc' }] });// "the newest one" +await ctx.api.object('candidate').find({ limit: 1 }); // any row will do +``` + +An unpredicated `find` / `count` is fine — returning or counting every row is an +honest answer. It is `findOne`'s implicit "just one of them" that turns a missing +predicate into a confidently wrong record. + **Update by id.** `update` reads the primary key out of `data`, so the single-record form is `update({ id, ...fieldsToChange })` — e.g. `update({ id: pos, status: 'filled' })`. diff --git a/skills/objectstack-data/rules/datasources.md b/skills/objectstack-data/rules/datasources.md index eab276f736..fb40dedb40 100644 --- a/skills/objectstack-data/rules/datasources.md +++ b/skills/objectstack-data/rules/datasources.md @@ -51,11 +51,16 @@ objects' read metadata registered **automatically at boot** — no `onEnable` / 1. it is **external** (`schemaMode !== 'managed'`), **or** 2. an object **explicitly** binds via `object.datasource === `, **or** -3. it sets **`autoConnect: true`**. +3. it sets **`autoConnect: true`**, **or** +4. a **`datasourceMapping` rule routes at least one object to it**. -A `managed` datasource that nothing explicitly binds (e.g. only referenced by a -`datasourceMapping` rule) stays **metadata-only** — visible but not connected — so -existing apps are unchanged. Set `autoConnect: true` to force a live connection. +A `managed` datasource that nothing routes to stays **metadata-only** — visible but +not connected. Set `autoConnect: true` to force a live connection. + +⚠️ A `datasourceMapping` rule is **routing, not a hint**. A rule pointing at a +datasource that cannot be connected fails the boot, and a query against a mapped +object throws instead of silently resolving the default store. Do not declare a +mapping you do not mean. > `onEnable` + `ctx.drivers.register(driver)` remains supported only as an escape > hatch for drivers built dynamically at runtime; it is idempotent with auto-connect. diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index bd86e85676..6bdb8ec5a9 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -21,15 +21,23 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes (#4410). +- `node_modules/@objectstack/spec/src/data/driver/config-registry.zod.ts` — The driver-id → `datasource.config` shape registry (#4410). +- `node_modules/@objectstack/spec/src/data/driver/memory.zod.ts` — Memory Driver Configuration Schema +- `node_modules/@objectstack/spec/src/data/driver/mongo.zod.ts` — MongoDB Standard Driver Protocol +- `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` +- `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/cluster.zod.ts` — Cluster Protocol - `node_modules/@objectstack/spec/src/kernel/metadata-customization.zod.ts` — Metadata Customization Layer Protocol -- `node_modules/@objectstack/spec/src/kernel/metadata-loader.zod.ts` — Metadata Loader Protocol +- `node_modules/@objectstack/spec/src/kernel/metadata-loader.zod.ts` — Metadata Manager Configuration - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema +- `node_modules/@objectstack/spec/src/shared/metadata-types.zod.ts` — Exports: MetadataFormatSchema, BaseMetadataRecordSchema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities - `node_modules/@objectstack/spec/src/system/tenant.zod.ts` — Tenant Schema (Multi-Tenant Architecture) diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 26de4c66ab..7bd93ea504 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -278,7 +278,7 @@ Sort with `orderBy` — an array of sort nodes: ### Keyset Pagination (Performant) -> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 18 (#4286).** No +> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 17 (#4286).** No > engine or driver ever read it — a query carrying `cursor` silently returned > **page 1 forever**. The key is tombstoned (a query carrying it fails to > parse with the prescription) and `QueryBuilder.cursor()` is gone. Do keyset @@ -443,7 +443,7 @@ Load related records through lookup/master_detail fields: ## Joins -> ⛔ **REMOVED in `@objectstack/spec` 18 (#4286, ADR-0049).** `query.joins` +> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** `query.joins` > (and the `JoinNode`/`JoinType`/`JoinStrategy` vocabulary) is gone from the > `QueryAST` schema — no engine or driver ever consumed it, so it only ever > declared a capability that did not run. The key is tombstoned: authoring it @@ -504,7 +504,7 @@ auto-default of name/title + short-text fields), resolved server-side. ## Window Functions (Analytics) -> ⛔ **REMOVED from the request surface in `@objectstack/spec` 18 (#4286).** +> ⛔ **REMOVED from the request surface in `@objectstack/spec` 17 (#4286).** > `query.windowFunctions` is gone from the `QueryAST` schema — the engine > never routed it to any driver, so every OVER clause it declared was > silently dropped. The key is tombstoned (a query carrying it fails to diff --git a/skills/objectstack-query/rules/aggregation.md b/skills/objectstack-query/rules/aggregation.md index 98157cd7fe..d3fd174df7 100644 --- a/skills/objectstack-query/rules/aggregation.md +++ b/skills/objectstack-query/rules/aggregation.md @@ -157,7 +157,7 @@ const [active] = await engine.aggregate('user', { ## Window Functions -> ⛔ **REMOVED in `@objectstack/spec` 18 (#4286, ADR-0049).** The `QueryAST` +> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** The `QueryAST` > schema no longer declares `windowFunctions` — the engine never routed the > property to any driver, so it was silently dropped. The key is tombstoned: > a query carrying it fails to parse with the upgrade prescription. The one diff --git a/skills/objectstack-query/rules/pagination.md b/skills/objectstack-query/rules/pagination.md index 6cb5312dde..6ffa5a79e6 100644 --- a/skills/objectstack-query/rules/pagination.md +++ b/skills/objectstack-query/rules/pagination.md @@ -9,7 +9,7 @@ Guide for implementing pagination in ObjectStack queries. | Offset | UI page navigation, small datasets | Simple, random page access | Slow on large offsets, drift on inserts | | Keyset (manual `where`) | Infinite scroll, real-time feeds | Consistent results, O(1) performance | No random page access | -> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` 18 +> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` 17 > (#4286).** No engine or driver ever read it: a query carrying `cursor` > silently returned **page 1 forever**. The key is tombstoned — a query > carrying it fails to parse with the prescription — and diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index afa474c37b..24f510aeed 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -997,7 +997,19 @@ The source is real React executed at render by the runtime. The injected scope a runtime from the public block registry (every non-container public block gets a PascalCase wrapper), so blocks like `` / `` exist even though the written contract below documents only the curated core set; - `` is the escape hatch for any other registered type + `` is the escape hatch for any other registered type. + **Exception — the `record:*` family is NOT usable here** (``, + ``, ``, ``, ``, + …): the registry injects a wrapper for each, but every one of them renders from + the record context a **record page** mounts, which a react page never does — so + they come back empty however you bind them. `os validate` rejects them here + (`react-block-needs-record-context`), by tag and via ``. + On a react page the parent record is ordinary React state, so use the blocks + that read their own props: `', '=', parentId]}>` for a related list, `` for a field panel, plain JSX over `useAdapter().findOne` for a + highlights strip or a stage bar. Need the family itself? Author the page as + `type:'record'`, where the context exists - `data` / `variables` / `page` Compose **layout with inline `style={{…}}`** (real CSS — see *Styling*, below); use the @@ -1013,10 +1025,11 @@ props/callbacks flow through — e.g. `` honors `objectName` / `mode > [`contracts/react-blocks.contract.json`](./contracts/react-blocks.contract.json). > It is the authoritative answer to "what props does ``/``/… > take?" — author against it, not from memory. The `data` props are sourced from the platform's spec schemas (FormView, -> ListView, RecordDetails, Chart, …) — the same protocol the server validates; +> ListView, Chart, …) — the same protocol the server validates; > `binding`/`controlled`/`callback` are the React overlay. The contract covers > the **curated core set**; runtime-injected blocks outside it (``, -> ``, …) read their props from the block registry at render time. +> ``, …) read their props from the block registry at render time — +> except the `record:*` family, which is rejected on this surface (above). > (Maintainers: regenerate with `pnpm --filter @objectstack/spec gen:react-blocks`.) Master/detail (click a row → edit it → save refreshes the list): diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index 5b4ff2568d..615ad6c271 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -413,224 +413,10 @@ } ] }, - { - "tag": "RecordDetails", - "schemaType": "record:details", - "summary": "Field-detail panel for the bound record. Config props from the spec RecordDetails schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record to show." - }, - { - "name": "columns", - "type": "'1' | '2' | '3' | '4'", - "kind": "data", - "required": false, - "description": "Number of columns for field layout (1-4)" - }, - { - "name": "layout", - "type": "'auto' | 'custom'", - "kind": "data", - "required": false, - "description": "Layout mode: auto uses object highlightFields, custom uses explicit sections" - }, - { - "name": "sections", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Section IDs to show (required when layout is \"custom\")" - }, - { - "name": "fields", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Explicit field list to display (optional, overrides highlightFields)" - } - ] - }, - { - "tag": "RecordHighlights", - "schemaType": "record:highlights", - "summary": "Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record to summarize." - }, - { - "name": "fields", - "type": "string | object[]", - "kind": "data", - "required": true, - "description": "Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline…" - }, - { - "name": "layout", - "type": "'horizontal' | 'vertical'", - "kind": "data", - "required": false, - "description": "Layout orientation for highlight fields" - } - ] - }, - { - "tag": "RecordRelatedList", - "schemaType": "record:related_list", - "summary": "Related child records via a lookup. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": true, - "description": "The RELATED (child) object whose records this list renders — e.g. objectName=\"invoice\" on an account page. NOT the parent object: the parent record is bound by recordId." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The parent record whose children are listed." - }, - { - "name": "relationshipField", - "type": "string", - "kind": "data", - "required": true, - "description": "Field on related object that points to this record (e.g., \"account_id\")" - }, - { - "name": "relationshipValueField", - "type": "string", - "kind": "data", - "required": false, - "description": "Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions)." - }, - { - "name": "columns", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list …" - }, - { - "name": "sort", - "type": "string | object[]", - "kind": "data", - "required": false, - "description": "Sort order for related records" - }, - { - "name": "limit", - "type": "integer", - "kind": "data", - "required": false, - "description": "Number of records to display initially" - }, - { - "name": "filter", - "type": "object[]", - "kind": "data", - "required": false, - "description": "Additional filter criteria for related records" - }, - { - "name": "title", - "type": "string", - "kind": "data", - "required": false, - "description": "Custom title for the related list" - }, - { - "name": "showViewAll", - "type": "boolean", - "kind": "data", - "required": false, - "description": "Show \"View All\" link to see all related records" - }, - { - "name": "actions", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Action IDs available for related records" - }, - { - "name": "add", - "type": "object", - "kind": "data", - "required": false, - "description": "Add-existing-via-picker config (generic m2m/junction assignment)." - } - ] - }, - { - "tag": "RecordPath", - "schemaType": "record:path", - "summary": "Stage/progress bar driven by a status field. Config props from the spec RecordPath schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record whose stage to show." - }, - { - "name": "statusField", - "type": "string", - "kind": "data", - "required": true, - "description": "Field name representing the current status/stage" - }, - { - "name": "stages", - "type": "object[]", - "kind": "data", - "required": false, - "description": "Explicit stage definitions (if not using field metadata)" - } - ] - }, { "tag": "Block", "schemaType": "(any)", - "summary": "Escape hatch — render any registered component by type. etc.", + "summary": "Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page's record context and are rejected here too (#4413).", "specSchema": false, "props": [ { diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 6d6fbd9d3a..f33063235d 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -35,6 +35,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities +- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/responsive.zod.ts` — Breakpoint Name Enum - `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index bbc168de26..d3d706a9b1 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -87,63 +87,9 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | `annotations` | `object[]` | data | | Reference lines/bands drawn over the plot: { type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? } | | `interaction` | `object` | data | | Interaction toggles: { tooltips?, brush? } | -## `` — `record:details` - -Field-detail panel for the bound record. Config props from the spec RecordDetails schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record to show. | -| `columns` | `'1' \| '2' \| '3' \| '4'` | data | | Number of columns for field layout (1-4) | -| `layout` | `'auto' \| 'custom'` | data | | Layout mode: auto uses object highlightFields, custom uses explicit sections | -| `sections` | `string[]` | data | | Section IDs to show (required when layout is "custom") | -| `fields` | `string[]` | data | | Explicit field list to display (optional, overrides highlightFields) | - -## `` — `record:highlights` - -Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record to summarize. | -| `fields` | `string \| object[]` | data | ✓ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline… | -| `layout` | `'horizontal' \| 'vertical'` | data | | Layout orientation for highlight fields | - -## `` — `record:related_list` - -Related child records via a lookup. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | ✓ | The RELATED (child) object whose records this list renders — e.g. objectName="invoice" on an account page. NOT the parent object: the parent record is bound by recordId. | -| `recordId` | `string \| number` | controlled | | The parent record whose children are listed. | -| `relationshipField` | `string` | data | ✓ | Field on related object that points to this record (e.g., "account_id") | -| `relationshipValueField` | `string` | data | | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | -| `columns` | `string[]` | data | | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list … | -| `sort` | `string \| object[]` | data | | Sort order for related records | -| `limit` | `integer` | data | | Number of records to display initially | -| `filter` | `object[]` | data | | Additional filter criteria for related records | -| `title` | `string` | data | | Custom title for the related list | -| `showViewAll` | `boolean` | data | | Show "View All" link to see all related records | -| `actions` | `string[]` | data | | Action IDs available for related records | -| `add` | `object` | data | | Add-existing-via-picker config (generic m2m/junction assignment). | - -## `` — `record:path` - -Stage/progress bar driven by a status field. Config props from the spec RecordPath schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record whose stage to show. | -| `statusField` | `string` | data | ✓ | Field name representing the current status/stage | -| `stages` | `object[]` | data | | Explicit stage definitions (if not using field metadata) | - ## `` — `(any)` *(no spec schema — overlay only)* -Escape hatch — render any registered component by type. etc. +Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page's record context and are rejected here too (#4413). | prop | type | kind | required | description | |------|------|------|:--------:|-------------|