@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/CHANGELOG.md +2340 -0
- package/dist/index.d.mts +318 -6
- package/dist/index.d.ts +318 -6
- package/dist/index.js +139 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
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),
|
|
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
|
|
399
|
-
* *mention* "update"
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -23,20 +23,25 @@ __export(index_exports, {
|
|
|
23
23
|
GLOBAL_UNIQUE_CONFIRMATION_REQUIRED: () => GLOBAL_UNIQUE_CONFIRMATION_REQUIRED,
|
|
24
24
|
GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION: () => GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION,
|
|
25
25
|
INTERNAL_ERROR_MESSAGE: () => INTERNAL_ERROR_MESSAGE,
|
|
26
|
+
VALIDATION_FAILED_STATUS: () => VALIDATION_FAILED_STATUS,
|
|
26
27
|
_resetEnvDeprecationWarnings: () => _resetEnvDeprecationWarnings,
|
|
27
28
|
buildGlobalUniqueStopMessage: () => buildGlobalUniqueStopMessage,
|
|
28
29
|
collectConfiguredLocales: () => collectConfiguredLocales,
|
|
29
30
|
collectGlobalUniques: () => collectGlobalUniques,
|
|
30
31
|
declaredIndexUniqueIsGlobal: () => declaredIndexUniqueIsGlobal,
|
|
32
|
+
declaredUserMessage: () => declaredUserMessage,
|
|
31
33
|
declaresServerFault: () => declaresServerFault,
|
|
34
|
+
demotedDeclaredCode: () => demotedDeclaredCode,
|
|
32
35
|
describeGlobalUniqueFinding: () => describeGlobalUniqueFinding,
|
|
33
36
|
emitDegradedBootBanner: () => emitDegradedBootBanner,
|
|
34
37
|
fieldUniqueIsGlobal: () => fieldUniqueIsGlobal,
|
|
38
|
+
fieldsFromZodIssues: () => fieldsFromZodIssues,
|
|
35
39
|
globalUniqueFindingId: () => globalUniqueFindingId,
|
|
36
40
|
isMcpServerEnabled: () => isMcpServerEnabled,
|
|
37
41
|
isModuleNotFoundError: () => isModuleNotFoundError,
|
|
38
42
|
isPlatformOwnedObject: () => isPlatformOwnedObject,
|
|
39
43
|
isRelationSubObjectPhrase: () => isRelationSubObjectPhrase,
|
|
44
|
+
isUnbackedConflictTargetError: () => isUnbackedConflictTargetError,
|
|
40
45
|
isUniqueViolationError: () => isUniqueViolationError,
|
|
41
46
|
keysetWalk: () => keysetWalk,
|
|
42
47
|
looksLikeInternalErrorLeak: () => looksLikeInternalErrorLeak,
|
|
@@ -53,11 +58,14 @@ __export(index_exports, {
|
|
|
53
58
|
resolveSandboxTimeoutMs: () => resolveSandboxTimeoutMs,
|
|
54
59
|
resolveSearchPinyinEnabled: () => resolveSearchPinyinEnabled,
|
|
55
60
|
resolveTenancyPosture: () => resolveTenancyPosture,
|
|
61
|
+
resolveThrownHttpError: () => resolveThrownHttpError,
|
|
56
62
|
sendError: () => sendError,
|
|
57
63
|
sendOk: () => sendOk,
|
|
58
64
|
stampSearchPinyinEnabled: () => stampSearchPinyinEnabled,
|
|
59
65
|
unconfirmedGlobalUniques: () => unconfirmedGlobalUniques,
|
|
60
|
-
uniqueViolationColumn: () => uniqueViolationColumn
|
|
66
|
+
uniqueViolationColumn: () => uniqueViolationColumn,
|
|
67
|
+
validationFailure: () => validationFailure,
|
|
68
|
+
validationFailureDetails: () => validationFailureDetails
|
|
61
69
|
});
|
|
62
70
|
module.exports = __toCommonJS(index_exports);
|
|
63
71
|
|
|
@@ -196,10 +204,47 @@ function _resetEnvDeprecationWarnings() {
|
|
|
196
204
|
|
|
197
205
|
// src/error-leak.ts
|
|
198
206
|
var INTERNAL_ERROR_MESSAGE = "Internal server error";
|
|
207
|
+
var DIALECT_LEAK_PHRASINGS = [
|
|
208
|
+
// Postgres 42P01 / 42703 (and, as a superstring, the `… of relation "…"`
|
|
209
|
+
// sub-object family: 42704 and friends). The quotes are required because
|
|
210
|
+
// Postgres always emits them here.
|
|
211
|
+
/\b(?:relation|column)\s+["'`][^"'`]+["'`]\s+does not exist/i,
|
|
212
|
+
// Postgres 42501. Restricted to physical object kinds: `schema`, `view`,
|
|
213
|
+
// `function` and `column` are all ObjectStack AUTHORING vocabulary, so a
|
|
214
|
+
// product message could legitimately use them and a miss is the cheap
|
|
215
|
+
// direction (the outcome is already a 5xx).
|
|
216
|
+
/\bpermission denied for (?:table|relation|sequence|database)\b/i,
|
|
217
|
+
// SQLite/libsql, message-only form. The `sqlite_` limb below catches these
|
|
218
|
+
// only when the driver prefixed its code; `better-sqlite3` and libsql both
|
|
219
|
+
// raise them bare, which is the shape measured across this repo.
|
|
220
|
+
/\bno such (?:table|column):/i,
|
|
221
|
+
// [#8739] MySQL/MariaDB ER_NO_SUCH_TABLE (1146): `Table 'app.t' doesn't
|
|
222
|
+
// exist`. Its own template, not a spelling of the Postgres one — MySQL
|
|
223
|
+
// contracts the verb and quotes `db.table` as a single identifier — so the
|
|
224
|
+
// `relation|column … does not exist` limb above cannot reach it. The quotes
|
|
225
|
+
// are required for the same reason they are there: the driver always emits
|
|
226
|
+
// them and prose about a table usually does not.
|
|
227
|
+
/\btable\s+["'`][^"'`]+["'`]\s+doesn't exist/i,
|
|
228
|
+
// [#8739] MySQL/MariaDB ER_BAD_FIELD_ERROR (1054): `Unknown column 'c' in
|
|
229
|
+
// 'field list'`. BOTH quoted parts are required. The second is MySQL's clause
|
|
230
|
+
// name — `field list`, `where clause`, `order clause`, `on clause` — and it
|
|
231
|
+
// is the half that makes this the driver's template rather than a sentence
|
|
232
|
+
// that merely calls a column unknown, which an import or mapping feature has
|
|
233
|
+
// every right to say.
|
|
234
|
+
/\bunknown column\s+["'`][^"'`]+["'`]\s+in\s+["'`][^"'`]+["'`]/i,
|
|
235
|
+
// [#8739] MySQL/MariaDB ER_DUP_ENTRY (1062): `Duplicate entry
|
|
236
|
+
// 'acme@example.com' for key 'crm_account.email'`. `for key` + a quoted index
|
|
237
|
+
// is the anchor; the VALUE half is matched loosely and lazily because it is
|
|
238
|
+
// the caller's own text and MySQL does not escape a quote inside it
|
|
239
|
+
// (`Duplicate entry 'O'Brien' for key 'i'` is a real shape). A bare
|
|
240
|
+
// `duplicate entry` with no `for key '…'` tail is not this template and is
|
|
241
|
+
// left alone.
|
|
242
|
+
/\bduplicate entry\s+["'`].*?["'`]\s+for key\s+["'`][^"'`]+["'`]/i
|
|
243
|
+
];
|
|
199
244
|
function looksLikeInternalErrorLeak(message) {
|
|
200
245
|
if (!message) return false;
|
|
201
246
|
const lower = String(message).toLowerCase();
|
|
202
|
-
return lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
247
|
+
return lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key") || DIALECT_LEAK_PHRASINGS.some((pattern) => pattern.test(lower));
|
|
203
248
|
}
|
|
204
249
|
function declaresServerFault(err) {
|
|
205
250
|
if (typeof err !== "object" || err === null) return false;
|
|
@@ -284,6 +329,71 @@ function sendError(res, status, code, message, extra) {
|
|
|
284
329
|
res.status(status).json({ success: false, error: { code, message, ...extra } });
|
|
285
330
|
}
|
|
286
331
|
|
|
332
|
+
// src/thrown-http-error.ts
|
|
333
|
+
var import_api2 = require("@objectstack/spec/api");
|
|
334
|
+
|
|
335
|
+
// src/validation-failure.ts
|
|
336
|
+
var import_api = require("@objectstack/spec/api");
|
|
337
|
+
var VALIDATION_FAILED_STATUS = 400;
|
|
338
|
+
function validationFailureDetails(err) {
|
|
339
|
+
if (!err) return void 0;
|
|
340
|
+
if (err.code !== "VALIDATION_FAILED" && err.name !== "ValidationError") return void 0;
|
|
341
|
+
return {
|
|
342
|
+
code: "VALIDATION_FAILED",
|
|
343
|
+
fields: Array.isArray(err.fields) ? err.fields : []
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function validationFailure(message, fields) {
|
|
347
|
+
const err = new Error(message);
|
|
348
|
+
err.name = "ValidationError";
|
|
349
|
+
err.code = "VALIDATION_FAILED";
|
|
350
|
+
err.fields = fields;
|
|
351
|
+
return err;
|
|
352
|
+
}
|
|
353
|
+
function fieldsFromZodIssues(issues, ...input) {
|
|
354
|
+
return (0, import_api.zodIssuesToFields)(issues, ...input).map(
|
|
355
|
+
(entry) => entry.field === "" ? { ...entry, field: "(body)" } : entry
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/thrown-http-error.ts
|
|
360
|
+
function resolveThrownHttpError(error, fallbackStatus = 500) {
|
|
361
|
+
const e = error;
|
|
362
|
+
const validation = validationFailureDetails(e);
|
|
363
|
+
const declaredStatus = typeof e?.status === "number" ? e.status : typeof e?.statusCode === "number" ? e.statusCode : validation ? VALIDATION_FAILED_STATUS : void 0;
|
|
364
|
+
const status = declaredStatus ?? fallbackStatus;
|
|
365
|
+
const spelled = typeof e?.code === "string" && e.code !== "" ? e.code : void 0;
|
|
366
|
+
const registered = spelled !== void 0 && import_api2.ErrorCode.safeParse(spelled).success ? spelled : void 0;
|
|
367
|
+
const code = validation ? validation.code : registered ?? (0, import_api2.standardErrorCodeForHttpStatus)(status);
|
|
368
|
+
const declaredCode = validation ? validation.code : spelled;
|
|
369
|
+
const issues = Array.isArray(e?.issues) ? e.issues : void 0;
|
|
370
|
+
const details = {
|
|
371
|
+
// A truthy NON-string `code` (a driver errno, say) is context and stays
|
|
372
|
+
// context — promoting it would put a number in the field callers branch on,
|
|
373
|
+
// which is the drift #3842 removed.
|
|
374
|
+
...!validation && e?.code && typeof e.code !== "string" ? { code: e.code } : {},
|
|
375
|
+
...issues ? { issues } : {},
|
|
376
|
+
...validation ? { fields: validation.fields } : {}
|
|
377
|
+
};
|
|
378
|
+
const userMessage = declaredUserMessage(error);
|
|
379
|
+
return {
|
|
380
|
+
status,
|
|
381
|
+
...declaredStatus !== void 0 ? { declaredStatus } : {},
|
|
382
|
+
code,
|
|
383
|
+
...declaredCode !== void 0 ? { declaredCode } : {},
|
|
384
|
+
message: typeof e?.message === "string" ? e.message : String(error),
|
|
385
|
+
...userMessage !== void 0 ? { userMessage } : {},
|
|
386
|
+
...Object.keys(details).length > 0 ? { details } : {}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function declaredUserMessage(error) {
|
|
390
|
+
const declared = error?.userMessage;
|
|
391
|
+
return typeof declared === "string" && declared.trim().length > 0 ? declared : void 0;
|
|
392
|
+
}
|
|
393
|
+
function demotedDeclaredCode(thrown) {
|
|
394
|
+
return thrown.declaredCode !== void 0 && thrown.declaredCode !== thrown.code ? thrown.declaredCode : void 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
287
397
|
// src/relation-sub-object.ts
|
|
288
398
|
function matchMissingColumnOfRelation(message) {
|
|
289
399
|
return MISSING_COLUMN_OF_RELATION.exec(message)?.[1];
|
|
@@ -298,7 +408,7 @@ var RELATION_SUB_OBJECT = /["'`][^"'`]+["'`]\s+of relation\s/i;
|
|
|
298
408
|
var UNIQUE_VIOLATION = {
|
|
299
409
|
codes: /* @__PURE__ */ new Set(["23505", "ER_DUP_ENTRY", "SQLITE_CONSTRAINT_UNIQUE"]),
|
|
300
410
|
errnos: /* @__PURE__ */ new Set([1062]),
|
|
301
|
-
message: /unique constraint|unique violation|duplicate key|duplicate entry/i
|
|
411
|
+
message: /unique constraint failed|violates unique constraint|unique violation|duplicate key|duplicate entry/i
|
|
302
412
|
};
|
|
303
413
|
var MAX_CAUSE_DEPTH = 4;
|
|
304
414
|
function isUniqueViolationError(error) {
|
|
@@ -360,6 +470,23 @@ function uniqueViolationColumn(error) {
|
|
|
360
470
|
return findUniqueViolationColumn(error, 0);
|
|
361
471
|
}
|
|
362
472
|
|
|
473
|
+
// src/unbacked-conflict-target.ts
|
|
474
|
+
var UNBACKED_CONFLICT_TARGET = {
|
|
475
|
+
message: /ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint|there is no unique or exclusion constraint matching the ON CONFLICT specification/i
|
|
476
|
+
};
|
|
477
|
+
var MAX_CAUSE_DEPTH2 = 4;
|
|
478
|
+
function isUnbackedConflictTargetError(error) {
|
|
479
|
+
return matchesUnbackedConflictTarget(error, 0);
|
|
480
|
+
}
|
|
481
|
+
function matchesUnbackedConflictTarget(error, depth) {
|
|
482
|
+
if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH2) return false;
|
|
483
|
+
if (typeof error === "string") return UNBACKED_CONFLICT_TARGET.message.test(error);
|
|
484
|
+
if (typeof error !== "object") return false;
|
|
485
|
+
const err = error;
|
|
486
|
+
if (typeof err.message === "string" && UNBACKED_CONFLICT_TARGET.message.test(err.message)) return true;
|
|
487
|
+
return matchesUnbackedConflictTarget(err.cause, depth + 1);
|
|
488
|
+
}
|
|
489
|
+
|
|
363
490
|
// src/unique-scope-install-gate.ts
|
|
364
491
|
var import_security2 = require("@objectstack/spec/security");
|
|
365
492
|
var SYS_OBJECT_PREFIXES = ["sys_", "base_"];
|
|
@@ -462,20 +589,25 @@ function postureGatesGlobalUniques(posture) {
|
|
|
462
589
|
GLOBAL_UNIQUE_CONFIRMATION_REQUIRED,
|
|
463
590
|
GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION,
|
|
464
591
|
INTERNAL_ERROR_MESSAGE,
|
|
592
|
+
VALIDATION_FAILED_STATUS,
|
|
465
593
|
_resetEnvDeprecationWarnings,
|
|
466
594
|
buildGlobalUniqueStopMessage,
|
|
467
595
|
collectConfiguredLocales,
|
|
468
596
|
collectGlobalUniques,
|
|
469
597
|
declaredIndexUniqueIsGlobal,
|
|
598
|
+
declaredUserMessage,
|
|
470
599
|
declaresServerFault,
|
|
600
|
+
demotedDeclaredCode,
|
|
471
601
|
describeGlobalUniqueFinding,
|
|
472
602
|
emitDegradedBootBanner,
|
|
473
603
|
fieldUniqueIsGlobal,
|
|
604
|
+
fieldsFromZodIssues,
|
|
474
605
|
globalUniqueFindingId,
|
|
475
606
|
isMcpServerEnabled,
|
|
476
607
|
isModuleNotFoundError,
|
|
477
608
|
isPlatformOwnedObject,
|
|
478
609
|
isRelationSubObjectPhrase,
|
|
610
|
+
isUnbackedConflictTargetError,
|
|
479
611
|
isUniqueViolationError,
|
|
480
612
|
keysetWalk,
|
|
481
613
|
looksLikeInternalErrorLeak,
|
|
@@ -492,10 +624,13 @@ function postureGatesGlobalUniques(posture) {
|
|
|
492
624
|
resolveSandboxTimeoutMs,
|
|
493
625
|
resolveSearchPinyinEnabled,
|
|
494
626
|
resolveTenancyPosture,
|
|
627
|
+
resolveThrownHttpError,
|
|
495
628
|
sendError,
|
|
496
629
|
sendOk,
|
|
497
630
|
stampSearchPinyinEnabled,
|
|
498
631
|
unconfirmedGlobalUniques,
|
|
499
|
-
uniqueViolationColumn
|
|
632
|
+
uniqueViolationColumn,
|
|
633
|
+
validationFailure,
|
|
634
|
+
validationFailureDetails
|
|
500
635
|
});
|
|
501
636
|
//# sourceMappingURL=index.js.map
|