@objectstack/types 17.0.0-rc.6 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TenancyPosture } from '@objectstack/spec/security';
2
- import { ErrorCode, ApiError } from '@objectstack/spec/api';
2
+ import { ErrorCode, ApiError, FieldErrorCode } from '@objectstack/spec/api';
3
3
 
4
4
  /**
5
5
  * Degraded-boot reporting, shared by every subsystem that can be told to boot
@@ -391,12 +391,18 @@ declare const INTERNAL_ERROR_MESSAGE = "Internal server error";
391
391
  *
392
392
  * Matches: dialect error codes (`SQLSTATE`, `sqlite_*`), bare statements
393
393
  * (a message that *starts* as `SELECT`/`INSERT INTO`/`UPDATE`/`DELETE FROM` —
394
- * drivers prefix the offending SQL to their message), and constraint-violation
395
- * dumps, which name physical tables and columns.
394
+ * drivers prefix the offending SQL to their message), constraint-violation
395
+ * dumps, which name physical tables and columns, and the
396
+ * {@link DIALECT_LEAK_PHRASINGS} the list covers — the SQLite family, Postgres
397
+ * and, since #8739, MySQL/MariaDB. A dialect outside that coverage (MSSQL and
398
+ * Oracle are the standing examples) makes this return FALSE without meaning the
399
+ * text is safe; read {@link DIALECT_LEAK_PHRASINGS}' note before sizing
400
+ * anything on a `false`.
396
401
  *
397
402
  * Does NOT match ordinary business or validation messages, which is why the
398
- * statement forms are anchored with `startsWith`: a legitimate message may
399
- * *mention* "update" without being one.
403
+ * statement forms are anchored with `startsWith` and the dialect phrasings on
404
+ * the driver's template: a legitimate message may *mention* "update", or say
405
+ * "does not exist" about a business record, without being either.
400
406
  */
401
407
  declare function looksLikeInternalErrorLeak(message: string | undefined | null): boolean;
402
408
  /**
@@ -680,6 +686,283 @@ declare function sendOk(res: EnvelopeResponse, data: unknown, status?: number):
680
686
  */
681
687
  declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCode, message: string, extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>): void;
682
688
 
689
+ /**
690
+ * The ONE rule for "what HTTP answer does a THROWN error declare?" (#8016).
691
+ *
692
+ * A service or protocol throw that carries its own `.status` / `.statusCode`
693
+ * and its own semantic `.code` is a *refusal*, not a fault: the caller asked
694
+ * for something the platform will not do, and the honest answer is that status
695
+ * with that code. A throw carrying neither is a fault, and the honest answer is
696
+ * the caller's fallback — 500 `INTERNAL_ERROR` at an HTTP boundary.
697
+ *
698
+ * ## Why this is shared rather than restated per door
699
+ *
700
+ * `/api/v1/packages` has **two** HTTP doors. The runtime dispatcher's
701
+ * `HttpDispatcher.errorFromThrown` read `.status` first and answered `409
702
+ * DESTRUCTIVE_CHANGE` for a `metadata-protocol` refusal. The direct-mount REST
703
+ * registrar (`packages/rest/src/package-routes.ts`) had four catch-alls that
704
+ * answered `500 INTERNAL_ERROR` regardless — and *that* registrar mounts first
705
+ * in the production stack, so 500 was what production actually returned. One
706
+ * throw, two answers, and the wrong one was the live one (#8016).
707
+ *
708
+ * The rule therefore lives in ONE function that both doors call. It could not
709
+ * live in `packages/runtime`: `@objectstack/runtime` depends on
710
+ * `@objectstack/rest`, so the arrow only points one way and `errorFromThrown`
711
+ * is unreachable from the REST door by construction. `@objectstack/types`
712
+ * depends on nothing but `@objectstack/spec`, which is exactly why the other
713
+ * shared HTTP-boundary helpers already live here — `looksLikeInternalErrorLeak`
714
+ * ("do not ship driver internals to clients") and `sendOk`/`sendError` ("write
715
+ * the declared envelope"). "What status does this throw mean?" is the same kind
716
+ * of property: it belongs to the boundary, not to one router.
717
+ *
718
+ * ## Two spellings of the code, because the two envelopes are not equally closed
719
+ *
720
+ * {@link ThrownHttpError.code} is narrowed to `StandardErrorCode ∪
721
+ * ERROR_CODE_LEDGER` — the union `ApiErrorSchema` validates against — so a
722
+ * throw whose `.code` is not a registered member does not get to name itself;
723
+ * it falls to the code the status derives. That is the same rule
724
+ * `metadata-protocol`'s `toRowApiError` applies to a per-row batch error, and
725
+ * it is what lets `sendError`'s closed `ErrorCode` parameter be satisfied
726
+ * without a cast. The direct-mount REST door needs exactly this: its bodies are
727
+ * parsed against `BaseResponseSchema` by its own conformance suite, so an
728
+ * unregistered code there is a failing test, not a wire answer.
729
+ *
730
+ * {@link ThrownHttpError.declaredCode} is the producer's own string, verbatim
731
+ * and un-narrowed. Until #9106 it was what the dispatcher door put in
732
+ * `error.code`; since the #9106 ruling it is what BOTH doors surface as the
733
+ * wire's `declaredCode` when it is not a vocabulary member (see below).
734
+ *
735
+ * [#8087] The first ruling on that gap (maintainer, 2026-08-12) kept the
736
+ * dispatcher's verbatim spelling and delivered a GATE — the unregistered
737
+ * producers are measured and classified
738
+ * (`packages/runtime/src/dispatcher-error-vocabulary.ts`,
739
+ * `pnpm check:dispatcher-error-vocabulary`) instead of named in prose here.
740
+ * The gate's own first derivation then measured the limb no registration can
741
+ * close: a metadata app's action code crosses the sandbox boundary carrying
742
+ * the app's OWN `.code` (#7867), authored by tenants at runtime.
743
+ *
744
+ * [#9106] That limb was ruled (maintainer, 2026-08-16): **`error.code` is a
745
+ * closed vocabulary at every door.** The dispatcher door now takes
746
+ * {@link ThrownHttpError.code} — the demote this resolver has always computed,
747
+ * and the REST door's spelling since #8016 — and a producer's unregistered
748
+ * string rides the wire's `declaredCode` (declared on `ApiErrorSchema`)
749
+ * instead of `error.code`. #7867's capability is preserved: the author's code
750
+ * still crosses the sandbox and still reaches the wire — in the open,
751
+ * author-authored channel, not the closed one. Use
752
+ * {@link demotedDeclaredCode} to read the spelling a boundary should surface
753
+ * beside the closed `code`.
754
+ *
755
+ * So the doors agree on **status** and on **code** unconditionally now — both
756
+ * answers come from ONE function, which is what keeps agreement a construction
757
+ * rather than two suites agreeing about literals.
758
+ *
759
+ * ## What this deliberately does NOT decide
760
+ *
761
+ * - **Message disclosure.** A 5xx message may name physical tables or carry a
762
+ * driver dump; withholding it is `looksLikeInternalErrorLeak`'s job, applied
763
+ * by the caller (the dispatcher does; see #3867). This function returns the
764
+ * thrown message verbatim.
765
+ * - **Whether a declared status is *plausible*.** No 400-599 band is imposed,
766
+ * because the dispatcher never imposed one and this function exists to make
767
+ * the two doors agree. Narrowing the accepted band is a change to the rule,
768
+ * and it belongs here — in one place, for both doors — if it is ever made.
769
+ */
770
+
771
+ /** The HTTP answer a thrown error declares. See {@link resolveThrownHttpError}. */
772
+ interface ThrownHttpError {
773
+ /** The producer's own `status`/`statusCode`, or the caller's fallback. */
774
+ status: number;
775
+ /**
776
+ * The status the THROW ITSELF declared — `.status`, `.statusCode`, or the
777
+ * 400 a validation-shaped throw declares by shape — and **absent** when it
778
+ * declared none, i.e. when {@link ThrownHttpError.status} above is the
779
+ * caller's `fallbackStatus`.
780
+ *
781
+ * ## Why `status` cannot answer this
782
+ *
783
+ * A producer that declares `500` and one that declares nothing both resolve
784
+ * to `status: 500`, so a caller that must tell "the producer said so" from
785
+ * "I supplied the default" cannot read it off the value. The workaround in
786
+ * the repo was to probe this function with a fallback no producer declares
787
+ * — `resolveThrownHttpError(e, 0).status !== 0`. That is a magic number
788
+ * standing in for a fact this function already computed, and it fails
789
+ * silently the day a producer declares the sentinel. So the fact is stated;
790
+ * `packages/rest`'s publish-classification suite now reads
791
+ * `resolveThrownHttpError(error).declaredStatus !== undefined` instead of
792
+ * hand-spelling the workaround.
793
+ *
794
+ * ## Who needs the distinction
795
+ *
796
+ * A sink that mirrors the status onto RESPONSE DATA instead of into the
797
+ * response's own status line — where the fallback would not be a default but
798
+ * an invention. `metadata-protocol`'s `toRowApiError` is the measured one
799
+ * (#8570): a batch row rides a **200**, so stamping `status` there would put
800
+ * `httpStatus: 500` on every undeclared driver fault, an ADDITION to the
801
+ * wire, where stamping `declaredStatus` restores only what a producer really
802
+ * declared. Boundaries that answer with the status itself keep reading
803
+ * `status` — the fallback is exactly what they want.
804
+ */
805
+ declaredStatus?: number;
806
+ /**
807
+ * A member of the declared ADR-0112 vocabulary — for a boundary whose
808
+ * envelope is checked against it. Never the HTTP status.
809
+ */
810
+ code: ErrorCode;
811
+ /**
812
+ * The producer's own code, verbatim and un-narrowed, or `undefined` when it
813
+ * declared none. Never for `error.code` — that slot takes {@link code} at
814
+ * every door (#9106) — but for the wire's `declaredCode` channel when the
815
+ * spelling is not a vocabulary member ({@link demotedDeclaredCode}). See the
816
+ * module note on why there are two.
817
+ */
818
+ declaredCode?: string;
819
+ /** The thrown message, UNSANITISED — see the module note on disclosure. */
820
+ message: string;
821
+ /**
822
+ * The producer's user-facing refusal text, verbatim — present exactly when
823
+ * the throw carried a non-empty string `userMessage` (#9934).
824
+ *
825
+ * This is the producer-side opt-in the objectui#5210 ruling asked for
826
+ * (maintainer, 2026-08-19, option 1): an application hook's refusal has no
827
+ * way to distinguish author-written user guidance from platform diagnostics,
828
+ * so the console substitutes a generic string on 403 (the recorded #3821
829
+ * fix) and every author-written remedy is suppressed with the diagnostics.
830
+ * A producer that sets `userMessage` on the thrown error is saying, at throw
831
+ * time, "this exact text is addressed to the END USER" — a consumer renders
832
+ * it verbatim and keeps the generic substitution for everything unmarked.
833
+ *
834
+ * Deliberately a FIELD carrying the text, not a boolean beside `message`:
835
+ * the mark and the marked text are one value, so a boundary that rewraps or
836
+ * substitutes `message` (sanitisation, truncation, the sandbox debug
837
+ * wrapper) can never accidentally promote platform prose into the marked
838
+ * channel — the #3821 protection holds by construction. Read through
839
+ * {@link declaredUserMessage}, never with an inline `typeof` probe.
840
+ *
841
+ * Status-agnostic on purpose (the ruling's second constraint): a 400, 403,
842
+ * 409 or 503 refusal may all carry it. It never REPLACES `message` — the
843
+ * diagnostic channel keeps its wording for logs and developers.
844
+ */
845
+ userMessage?: string;
846
+ /**
847
+ * Structured context: spec-validation `issues[]`, record-validation
848
+ * `fields[]`. Absent rather than `{}` when the throw carried none, so an
849
+ * empty object never reads as "there is context here".
850
+ */
851
+ details?: Record<string, unknown>;
852
+ }
853
+ /**
854
+ * Resolve a thrown error into the status, code, message and structured context
855
+ * an HTTP boundary should answer with.
856
+ *
857
+ * Precedence, in order:
858
+ *
859
+ * | Question | Answer |
860
+ * |---|---|
861
+ * | status | `.status` → `.statusCode` → 400 if it is a validation failure → `fallbackStatus` |
862
+ * | declaredStatus | the same chain WITHOUT the fallback — absent when the throw declared none |
863
+ * | code | `VALIDATION_FAILED` if it is one → a REGISTERED `.code` → derived from the status |
864
+ * | declaredCode | `VALIDATION_FAILED` if it is one → any non-empty string `.code` → absent |
865
+ * | message | `.message` when it is a string → `String(error)` |
866
+ * | userMessage | a non-empty string `.userMessage` → absent (see {@link declaredUserMessage}) |
867
+ *
868
+ * Both status spellings are read because both are produced in this repo:
869
+ * `plugin-approvals`' lifecycle hooks and `metadata-protocol` throw
870
+ * `statusCode`, `metadata-protocol`'s conflicts throw `status`. Reading one
871
+ * spelling is how `/api/v1/data` answered 500 for a deliberate `409
872
+ * RECORD_LOCKED` until #7525.
873
+ */
874
+ declare function resolveThrownHttpError(error: unknown, fallbackStatus?: number): ThrownHttpError;
875
+ /**
876
+ * The user-facing refusal text a thrown error DECLARED, or `undefined` when it
877
+ * declared none (#9934). See {@link ThrownHttpError.userMessage} for what the
878
+ * declaration means and why it is a text-carrying field rather than a flag.
879
+ *
880
+ * The ONE read every boundary applies — the REST classification door, the
881
+ * dispatcher door, and the sandbox side-channel all call this rather than
882
+ * probing `error.userMessage` themselves, so "what counts as marked" cannot
883
+ * fork per door the way the `status`/`statusCode` spelling once did (#7525).
884
+ *
885
+ * A non-string or blank `userMessage` is NOT a declaration: `undefined`, a
886
+ * number, `''` and whitespace-only all answer `undefined`, so nothing invents
887
+ * a marked message for a producer that never wrote one — absent means the
888
+ * consumer keeps its generic substitution (#3821 preserved by construction).
889
+ */
890
+ declare function declaredUserMessage(error: unknown): string | undefined;
891
+ /**
892
+ * The producer's spelling a boundary should surface as the wire's
893
+ * `declaredCode` beside the closed `code` — or `undefined` when there is
894
+ * nothing to surface (#9106).
895
+ *
896
+ * Present exactly when the throw spelled a code that did NOT survive into
897
+ * {@link ThrownHttpError.code} — i.e. the demote happened. A registered code
898
+ * is already in `code`, so emitting it again would put two spellings of one
899
+ * fact on every refusal; a throw with no code has nothing to declare. Spelled
900
+ * once here rather than as three `!==` comparisons at three exits, so
901
+ * "presence means demotion" (`ApiErrorSchema.declaredCode`'s documented
902
+ * semantics) has one definition.
903
+ */
904
+ declare function demotedDeclaredCode(thrown: ThrownHttpError): string | undefined;
905
+
906
+ /** The HTTP status a validation failure maps to when the error names none. */
907
+ declare const VALIDATION_FAILED_STATUS = 400;
908
+ interface ValidationFailureDetails {
909
+ code: 'VALIDATION_FAILED';
910
+ /** Per-field envelopes, passed through verbatim. `[]` when absent/malformed. */
911
+ fields: unknown[];
912
+ }
913
+ /**
914
+ * Structured `details` for a thrown validation failure, or `undefined` when
915
+ * `err` is not one. Callers use the `undefined` result as the predicate and the
916
+ * returned object as the `details` payload, so the two can never disagree.
917
+ */
918
+ declare function validationFailureDetails(err: any): ValidationFailureDetails | undefined;
919
+ /**
920
+ * [#3878/#3899] The CONSTRUCTOR for the shape {@link validationFailureDetails}
921
+ * recognises — kept in the same module so the two can never drift. Thrown from
922
+ * a domain handler, both dispatcher error exits map it to
923
+ * `400 VALIDATION_FAILED` + `details.fields[]` (#3918) with no new error
924
+ * channel and no runtime dependency on objectql's `ValidationError` class.
925
+ * First built inline by the analytics domain; hoisted here when notifications
926
+ * and automation grew the same entry gates rather than a third copy.
927
+ */
928
+ declare function validationFailure(message: string, fields: unknown[]): Error;
929
+ /**
930
+ * Zod issues → the dispatcher's `fields[]` envelope entries
931
+ * (`{ field, code, message }`). `'(body)'` names a root-level failure — a body
932
+ * that is the wrong TYPE entirely has no path to point at.
933
+ *
934
+ * ## The `code` is an ADR-0114 `FieldErrorCode`, not Zod's (#8124)
935
+ *
936
+ * This used to assign `issue.code` verbatim, which put Zod's own vocabulary
937
+ * (`unrecognized_keys`, `too_small`, …) on a wire position
938
+ * `FieldErrorSchema.code` declares as a CLOSED catalog — the exact
939
+ * pass-through ADR-0114 D3 closed on the REST transport. It now maps through
940
+ * `zodIssuesToFields`, the one D3 implementation in the repo, which lives in
941
+ * `@objectstack/spec` beside the catalog it is total over (this package cannot
942
+ * import `@objectstack/rest`, where the compliant copy grew up — the
943
+ * dependency arrow points the other way, which is what #8124 moved it for).
944
+ *
945
+ * Two things ride along, both additive:
946
+ *
947
+ * - **The optional `input`** (the value that was parsed) buys the D3
948
+ * `invalid_type` split: with it a MISSING required property is reported as
949
+ * `required` instead of the `invalid_type` Zod spells it as. Callers without
950
+ * the input at hand degrade per the D3 table — every code is still a
951
+ * catalog member.
952
+ * - **Union expansion (#5014)**: a rejection behind a `z.union` yields the
953
+ * union's own entry PLUS the branch entries that explain it, so entry count
954
+ * is not issue count. Read `fields.length` as the number of field errors.
955
+ */
956
+ declare function fieldsFromZodIssues(issues: Array<{
957
+ path: Array<string | number | symbol>;
958
+ code: string;
959
+ message: string;
960
+ }>, ...input: [] | [unknown]): Array<{
961
+ field: string;
962
+ code: FieldErrorCode;
963
+ message: string;
964
+ }>;
965
+
683
966
  /**
684
967
  * The one home for Postgres' `«sub-object» "x" of relation "y" …` phrasing
685
968
  * (#6615).
@@ -871,6 +1154,35 @@ declare function isUniqueViolationError(error: unknown): boolean;
871
1154
  */
872
1155
  declare function uniqueViolationColumn(error: unknown): string | undefined;
873
1156
 
1157
+ /**
1158
+ * Whether a thrown driver error says the `ON CONFLICT` target it was given is
1159
+ * backed by no PRIMARY KEY or UNIQUE index.
1160
+ *
1161
+ * Reads the message channel, then one step at a time down the `cause` chain —
1162
+ * pool and query-builder layers re-throw with the original attached, and the
1163
+ * refusal this predicate gates keeps the raw error as its own `cause`. A plain
1164
+ * string is judged directly, so a caller that already unwrapped `err.message`
1165
+ * can pass it in.
1166
+ *
1167
+ * **Unrecognised is always `false`.** A false positive is the expensive
1168
+ * direction: it tells a caller to go add an index when the real failure was a
1169
+ * syntax error, a missing table, or — worst — a genuine unique violation on an
1170
+ * index that exists and works. A false negative costs only the raw error that
1171
+ * was the status quo before recognition existed.
1172
+ *
1173
+ * @param error - the thrown value, of any shape.
1174
+ *
1175
+ * @example
1176
+ * ```ts
1177
+ * catch (error) {
1178
+ * // ⚠️ NOT isUniqueViolationError — that is the opposite condition.
1179
+ * if (isUnbackedConflictTargetError(error)) throw refuseUnbackedConflictTarget(object, keys, error);
1180
+ * throw error;
1181
+ * }
1182
+ * ```
1183
+ */
1184
+ declare function isUnbackedConflictTargetError(error: unknown): boolean;
1185
+
874
1186
  /**
875
1187
  * [ADR-0120 D5e] The `isolated`-posture install gate for `'global'` uniqueness.
876
1188
  *
@@ -1095,4 +1407,4 @@ interface RuntimePlugin {
1095
1407
  onStart?: (ctx: RuntimeContext) => void | Promise<void>;
1096
1408
  }
1097
1409
 
1098
- export { type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, type RuntimeContext, type RuntimePlugin, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declaredIndexUniqueIsGlobal, declaresServerFault, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, globalUniqueFindingId, isMcpServerEnabled, isModuleNotFoundError, isPlatformOwnedObject, isRelationSubObjectPhrase, isUniqueViolationError, keysetWalk, looksLikeInternalErrorLeak, matchMissingColumnOfRelation, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques, uniqueViolationColumn };
1410
+ export { type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, type RuntimeContext, type RuntimePlugin, type ThrownHttpError, VALIDATION_FAILED_STATUS, type ValidationFailureDetails, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declaredIndexUniqueIsGlobal, declaredUserMessage, declaresServerFault, demotedDeclaredCode, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, fieldsFromZodIssues, globalUniqueFindingId, isMcpServerEnabled, isModuleNotFoundError, isPlatformOwnedObject, isRelationSubObjectPhrase, isUnbackedConflictTargetError, isUniqueViolationError, keysetWalk, looksLikeInternalErrorLeak, matchMissingColumnOfRelation, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, resolveThrownHttpError, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques, uniqueViolationColumn, validationFailure, validationFailureDetails };