@objectstack/types 17.0.0-rc.5 → 17.0.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 +2293 -0
- package/dist/index.d.mts +429 -6
- package/dist/index.d.ts +429 -6
- package/dist/index.js +187 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +176 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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,14 @@ 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} of the engines this repo ships.
|
|
396
397
|
*
|
|
397
398
|
* Does NOT match ordinary business or validation messages, which is why the
|
|
398
|
-
* statement forms are anchored with `startsWith
|
|
399
|
-
* *mention* "update"
|
|
399
|
+
* statement forms are anchored with `startsWith` and the dialect phrasings on
|
|
400
|
+
* the driver's template: a legitimate message may *mention* "update", or say
|
|
401
|
+
* "does not exist" about a business record, without being either.
|
|
400
402
|
*/
|
|
401
403
|
declare function looksLikeInternalErrorLeak(message: string | undefined | null): boolean;
|
|
402
404
|
/**
|
|
@@ -680,6 +682,427 @@ declare function sendOk(res: EnvelopeResponse, data: unknown, status?: number):
|
|
|
680
682
|
*/
|
|
681
683
|
declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCode, message: string, extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>): void;
|
|
682
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The ONE rule for "what HTTP answer does a THROWN error declare?" (#8016).
|
|
687
|
+
*
|
|
688
|
+
* A service or protocol throw that carries its own `.status` / `.statusCode`
|
|
689
|
+
* and its own semantic `.code` is a *refusal*, not a fault: the caller asked
|
|
690
|
+
* for something the platform will not do, and the honest answer is that status
|
|
691
|
+
* with that code. A throw carrying neither is a fault, and the honest answer is
|
|
692
|
+
* the caller's fallback — 500 `INTERNAL_ERROR` at an HTTP boundary.
|
|
693
|
+
*
|
|
694
|
+
* ## Why this is shared rather than restated per door
|
|
695
|
+
*
|
|
696
|
+
* `/api/v1/packages` has **two** HTTP doors. The runtime dispatcher's
|
|
697
|
+
* `HttpDispatcher.errorFromThrown` read `.status` first and answered `409
|
|
698
|
+
* DESTRUCTIVE_CHANGE` for a `metadata-protocol` refusal. The direct-mount REST
|
|
699
|
+
* registrar (`packages/rest/src/package-routes.ts`) had four catch-alls that
|
|
700
|
+
* answered `500 INTERNAL_ERROR` regardless — and *that* registrar mounts first
|
|
701
|
+
* in the production stack, so 500 was what production actually returned. One
|
|
702
|
+
* throw, two answers, and the wrong one was the live one (#8016).
|
|
703
|
+
*
|
|
704
|
+
* The rule therefore lives in ONE function that both doors call. It could not
|
|
705
|
+
* live in `packages/runtime`: `@objectstack/runtime` depends on
|
|
706
|
+
* `@objectstack/rest`, so the arrow only points one way and `errorFromThrown`
|
|
707
|
+
* is unreachable from the REST door by construction. `@objectstack/types`
|
|
708
|
+
* depends on nothing but `@objectstack/spec`, which is exactly why the other
|
|
709
|
+
* shared HTTP-boundary helpers already live here — `looksLikeInternalErrorLeak`
|
|
710
|
+
* ("do not ship driver internals to clients") and `sendOk`/`sendError` ("write
|
|
711
|
+
* the declared envelope"). "What status does this throw mean?" is the same kind
|
|
712
|
+
* of property: it belongs to the boundary, not to one router.
|
|
713
|
+
*
|
|
714
|
+
* ## Two spellings of the code, because the two envelopes are not equally closed
|
|
715
|
+
*
|
|
716
|
+
* {@link ThrownHttpError.code} is narrowed to `StandardErrorCode ∪
|
|
717
|
+
* ERROR_CODE_LEDGER` — the union `ApiErrorSchema` validates against — so a
|
|
718
|
+
* throw whose `.code` is not a registered member does not get to name itself;
|
|
719
|
+
* it falls to the code the status derives. That is the same rule
|
|
720
|
+
* `metadata-protocol`'s `toRowApiError` applies to a per-row batch error, and
|
|
721
|
+
* it is what lets `sendError`'s closed `ErrorCode` parameter be satisfied
|
|
722
|
+
* without a cast. The direct-mount REST door needs exactly this: its bodies are
|
|
723
|
+
* parsed against `BaseResponseSchema` by its own conformance suite, so an
|
|
724
|
+
* unregistered code there is a failing test, not a wire answer.
|
|
725
|
+
*
|
|
726
|
+
* {@link ThrownHttpError.declaredCode} is the producer's own string, verbatim
|
|
727
|
+
* and un-narrowed, which is what the dispatcher door has always put on the
|
|
728
|
+
* wire — `STORAGE_FAILURE`, `FLOW_FAILED` and `DUPLICATE` are all unregistered
|
|
729
|
+
* and all pinned by existing dispatcher tests. Narrowing it here would rewrite
|
|
730
|
+
* a behaviour three suites assert, which is a contract decision (should the
|
|
731
|
+
* dispatcher's `error.code` be closed too?) and not this function's to take.
|
|
732
|
+
*
|
|
733
|
+
* So the doors agree on **status** unconditionally and on **code** for every
|
|
734
|
+
* registered code, and differ only where a producer emits a code the ledger
|
|
735
|
+
* does not know — a case that is already a contract violation on either door.
|
|
736
|
+
* Both answers come from ONE function, which is what keeps that difference a
|
|
737
|
+
* documented one rather than a drift.
|
|
738
|
+
*
|
|
739
|
+
* ## What this deliberately does NOT decide
|
|
740
|
+
*
|
|
741
|
+
* - **Message disclosure.** A 5xx message may name physical tables or carry a
|
|
742
|
+
* driver dump; withholding it is `looksLikeInternalErrorLeak`'s job, applied
|
|
743
|
+
* by the caller (the dispatcher does; see #3867). This function returns the
|
|
744
|
+
* thrown message verbatim.
|
|
745
|
+
* - **Whether a declared status is *plausible*.** No 400-599 band is imposed,
|
|
746
|
+
* because the dispatcher never imposed one and this function exists to make
|
|
747
|
+
* the two doors agree. Narrowing the accepted band is a change to the rule,
|
|
748
|
+
* and it belongs here — in one place, for both doors — if it is ever made.
|
|
749
|
+
*/
|
|
750
|
+
|
|
751
|
+
/** The HTTP answer a thrown error declares. See {@link resolveThrownHttpError}. */
|
|
752
|
+
interface ThrownHttpError {
|
|
753
|
+
/** The producer's own `status`/`statusCode`, or the caller's fallback. */
|
|
754
|
+
status: number;
|
|
755
|
+
/**
|
|
756
|
+
* The status the THROW ITSELF declared — `.status`, `.statusCode`, or the
|
|
757
|
+
* 400 a validation-shaped throw declares by shape — and **absent** when it
|
|
758
|
+
* declared none, i.e. when {@link ThrownHttpError.status} above is the
|
|
759
|
+
* caller's `fallbackStatus`.
|
|
760
|
+
*
|
|
761
|
+
* ## Why `status` cannot answer this
|
|
762
|
+
*
|
|
763
|
+
* A producer that declares `500` and one that declares nothing both resolve
|
|
764
|
+
* to `status: 500`, so a caller that must tell "the producer said so" from
|
|
765
|
+
* "I supplied the default" cannot read it off the value. The workaround in
|
|
766
|
+
* the repo was to probe this function with a fallback no producer declares
|
|
767
|
+
* — `resolveThrownHttpError(e, 0).status !== 0`, still spelled by hand in
|
|
768
|
+
* `packages/rest`'s publish-classification suite. That is a magic number
|
|
769
|
+
* standing in for a fact this function already computed, and it fails
|
|
770
|
+
* silently the day a producer declares the sentinel. So the fact is stated.
|
|
771
|
+
*
|
|
772
|
+
* ## Who needs the distinction
|
|
773
|
+
*
|
|
774
|
+
* A sink that mirrors the status onto RESPONSE DATA instead of into the
|
|
775
|
+
* response's own status line — where the fallback would not be a default but
|
|
776
|
+
* an invention. `metadata-protocol`'s `toRowApiError` is the measured one
|
|
777
|
+
* (#8570): a batch row rides a **200**, so stamping `status` there would put
|
|
778
|
+
* `httpStatus: 500` on every undeclared driver fault, an ADDITION to the
|
|
779
|
+
* wire, where stamping `declaredStatus` restores only what a producer really
|
|
780
|
+
* declared. Boundaries that answer with the status itself keep reading
|
|
781
|
+
* `status` — the fallback is exactly what they want.
|
|
782
|
+
*/
|
|
783
|
+
declaredStatus?: number;
|
|
784
|
+
/**
|
|
785
|
+
* A member of the declared ADR-0112 vocabulary — for a boundary whose
|
|
786
|
+
* envelope is checked against it. Never the HTTP status.
|
|
787
|
+
*/
|
|
788
|
+
code: ErrorCode;
|
|
789
|
+
/**
|
|
790
|
+
* The producer's own code, verbatim and un-narrowed, or `undefined` when it
|
|
791
|
+
* declared none. For the dispatcher door, whose `error.code` is not closed in
|
|
792
|
+
* practice. See the module note on why there are two.
|
|
793
|
+
*/
|
|
794
|
+
declaredCode?: string;
|
|
795
|
+
/** The thrown message, UNSANITISED — see the module note on disclosure. */
|
|
796
|
+
message: string;
|
|
797
|
+
/**
|
|
798
|
+
* Structured context: spec-validation `issues[]`, record-validation
|
|
799
|
+
* `fields[]`. Absent rather than `{}` when the throw carried none, so an
|
|
800
|
+
* empty object never reads as "there is context here".
|
|
801
|
+
*/
|
|
802
|
+
details?: Record<string, unknown>;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Resolve a thrown error into the status, code, message and structured context
|
|
806
|
+
* an HTTP boundary should answer with.
|
|
807
|
+
*
|
|
808
|
+
* Precedence, in order:
|
|
809
|
+
*
|
|
810
|
+
* | Question | Answer |
|
|
811
|
+
* |---|---|
|
|
812
|
+
* | status | `.status` → `.statusCode` → 400 if it is a validation failure → `fallbackStatus` |
|
|
813
|
+
* | declaredStatus | the same chain WITHOUT the fallback — absent when the throw declared none |
|
|
814
|
+
* | code | `VALIDATION_FAILED` if it is one → a REGISTERED `.code` → derived from the status |
|
|
815
|
+
* | declaredCode | `VALIDATION_FAILED` if it is one → any non-empty string `.code` → absent |
|
|
816
|
+
* | message | `.message` when it is a string → `String(error)` |
|
|
817
|
+
*
|
|
818
|
+
* Both status spellings are read because both are produced in this repo:
|
|
819
|
+
* `plugin-approvals`' lifecycle hooks and `metadata-protocol` throw
|
|
820
|
+
* `statusCode`, `metadata-protocol`'s conflicts throw `status`. Reading one
|
|
821
|
+
* spelling is how `/api/v1/data` answered 500 for a deliberate `409
|
|
822
|
+
* RECORD_LOCKED` until #7525.
|
|
823
|
+
*/
|
|
824
|
+
declare function resolveThrownHttpError(error: unknown, fallbackStatus?: number): ThrownHttpError;
|
|
825
|
+
|
|
826
|
+
/** The HTTP status a validation failure maps to when the error names none. */
|
|
827
|
+
declare const VALIDATION_FAILED_STATUS = 400;
|
|
828
|
+
interface ValidationFailureDetails {
|
|
829
|
+
code: 'VALIDATION_FAILED';
|
|
830
|
+
/** Per-field envelopes, passed through verbatim. `[]` when absent/malformed. */
|
|
831
|
+
fields: unknown[];
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Structured `details` for a thrown validation failure, or `undefined` when
|
|
835
|
+
* `err` is not one. Callers use the `undefined` result as the predicate and the
|
|
836
|
+
* returned object as the `details` payload, so the two can never disagree.
|
|
837
|
+
*/
|
|
838
|
+
declare function validationFailureDetails(err: any): ValidationFailureDetails | undefined;
|
|
839
|
+
/**
|
|
840
|
+
* [#3878/#3899] The CONSTRUCTOR for the shape {@link validationFailureDetails}
|
|
841
|
+
* recognises — kept in the same module so the two can never drift. Thrown from
|
|
842
|
+
* a domain handler, both dispatcher error exits map it to
|
|
843
|
+
* `400 VALIDATION_FAILED` + `details.fields[]` (#3918) with no new error
|
|
844
|
+
* channel and no runtime dependency on objectql's `ValidationError` class.
|
|
845
|
+
* First built inline by the analytics domain; hoisted here when notifications
|
|
846
|
+
* and automation grew the same entry gates rather than a third copy.
|
|
847
|
+
*/
|
|
848
|
+
declare function validationFailure(message: string, fields: unknown[]): Error;
|
|
849
|
+
/**
|
|
850
|
+
* Zod issues → the dispatcher's `fields[]` envelope entries
|
|
851
|
+
* (`{ field, code, message }`). `'(body)'` names a root-level failure — a body
|
|
852
|
+
* that is the wrong TYPE entirely has no path to point at.
|
|
853
|
+
*
|
|
854
|
+
* ## The `code` is an ADR-0114 `FieldErrorCode`, not Zod's (#8124)
|
|
855
|
+
*
|
|
856
|
+
* This used to assign `issue.code` verbatim, which put Zod's own vocabulary
|
|
857
|
+
* (`unrecognized_keys`, `too_small`, …) on a wire position
|
|
858
|
+
* `FieldErrorSchema.code` declares as a CLOSED catalog — the exact
|
|
859
|
+
* pass-through ADR-0114 D3 closed on the REST transport. It now maps through
|
|
860
|
+
* `zodIssuesToFields`, the one D3 implementation in the repo, which lives in
|
|
861
|
+
* `@objectstack/spec` beside the catalog it is total over (this package cannot
|
|
862
|
+
* import `@objectstack/rest`, where the compliant copy grew up — the
|
|
863
|
+
* dependency arrow points the other way, which is what #8124 moved it for).
|
|
864
|
+
*
|
|
865
|
+
* Two things ride along, both additive:
|
|
866
|
+
*
|
|
867
|
+
* - **The optional `input`** (the value that was parsed) buys the D3
|
|
868
|
+
* `invalid_type` split: with it a MISSING required property is reported as
|
|
869
|
+
* `required` instead of the `invalid_type` Zod spells it as. Callers without
|
|
870
|
+
* the input at hand degrade per the D3 table — every code is still a
|
|
871
|
+
* catalog member.
|
|
872
|
+
* - **Union expansion (#5014)**: a rejection behind a `z.union` yields the
|
|
873
|
+
* union's own entry PLUS the branch entries that explain it, so entry count
|
|
874
|
+
* is not issue count. Read `fields.length` as the number of field errors.
|
|
875
|
+
*/
|
|
876
|
+
declare function fieldsFromZodIssues(issues: Array<{
|
|
877
|
+
path: Array<string | number | symbol>;
|
|
878
|
+
code: string;
|
|
879
|
+
message: string;
|
|
880
|
+
}>, ...input: [] | [unknown]): Array<{
|
|
881
|
+
field: string;
|
|
882
|
+
code: FieldErrorCode;
|
|
883
|
+
message: string;
|
|
884
|
+
}>;
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* The one home for Postgres' `«sub-object» "x" of relation "y" …` phrasing
|
|
888
|
+
* (#6615).
|
|
889
|
+
*
|
|
890
|
+
* ## The superstring hole, stated once
|
|
891
|
+
*
|
|
892
|
+
* Postgres phrases a failure about something *inside* a relation by naming the
|
|
893
|
+
* relation too:
|
|
894
|
+
*
|
|
895
|
+
* ```
|
|
896
|
+
* column "label" of relation "sys_team" does not exist (42703)
|
|
897
|
+
* constraint "uq_sys_team_name" of relation "sys_team" does not exist (42704)
|
|
898
|
+
* column "environment_id" of relation "sys_metadata" already exists (42701)
|
|
899
|
+
* ```
|
|
900
|
+
*
|
|
901
|
+
* Every one of those **contains a complete, legal missing-TABLE phrase** —
|
|
902
|
+
* `relation "sys_team" does not exist` — as a substring, while meaning the
|
|
903
|
+
* opposite: the relation is right there, which is precisely why it could be
|
|
904
|
+
* named. No amount of tightening a "does this say a relation is missing?"
|
|
905
|
+
* regex can remove that match, because the phrase really is in there. The only
|
|
906
|
+
* repair is to ask the more specific question FIRST. That makes the ORDER the
|
|
907
|
+
* fix, not the pattern — and it is why three packages each grew their own copy
|
|
908
|
+
* of this phrase (#5352, #6035/PR #6346, #6347/PR #6613) before it was given a
|
|
909
|
+
* home.
|
|
910
|
+
*
|
|
911
|
+
* ## Two widths, on purpose — never collapse them
|
|
912
|
+
*
|
|
913
|
+
* The three consumers do not want the same regex, and the difference is not
|
|
914
|
+
* sloppiness: it is **which direction of error is safe** at each site.
|
|
915
|
+
*
|
|
916
|
+
* | consumer | asks | uses | a MISS costs |
|
|
917
|
+
* |:---|:---|:---|:---|
|
|
918
|
+
* | `@objectstack/rest` `mapDataError` (#5352) | which column? | {@link matchMissingColumnOfRelation} | a vaguer message (`404` instead of `400 INVALID_FIELD`) |
|
|
919
|
+
* | `@objectstack/service-analytics` `isMissingSourceError` / `missingSourceRelation` (#6035) | is this a missing COLUMN, so keep it hard? | {@link matchMissingColumnOfRelation} | a mistyped column degrades to a confident empty chart |
|
|
920
|
+
* | `@objectstack/metadata` `MISSING_TABLE.excludes` (#6347) | is this about a sub-object, so not a missing table? | {@link isRelationSubObjectPhrase} | a corruption verdict returns (`event_seq` restarts at 1) |
|
|
921
|
+
*
|
|
922
|
+
* The first two **extract**, so they must be strict: over-matching there would
|
|
923
|
+
* turn a genuinely missing table into a hard failure and regress #5033's
|
|
924
|
+
* deliberate leniency, while under-matching merely keeps today's verdict. The
|
|
925
|
+
* third **excludes**, so it is deliberately wider — any sub-object, any quoted
|
|
926
|
+
* identifier, any verdict — because over-matching there only ever converts a
|
|
927
|
+
* benign verdict into a loud one, and a miss restores data corruption.
|
|
928
|
+
*
|
|
929
|
+
* Collapsing the two into one regex would therefore be wrong for one caller
|
|
930
|
+
* whichever width won. They are two exports for that reason, and the reason is
|
|
931
|
+
* load-bearing rather than stylistic.
|
|
932
|
+
*
|
|
933
|
+
* ## Home
|
|
934
|
+
*
|
|
935
|
+
* `@objectstack/types`, following `isUniqueViolationError`'s move
|
|
936
|
+
* (#6250 — four hand-written answers to one question) and
|
|
937
|
+
* `isModuleNotFoundError`'s (framework#3265 — "single shared owner … so the
|
|
938
|
+
* parallel loaders cannot drift apart"). This module deliberately imports
|
|
939
|
+
* nothing.
|
|
940
|
+
*
|
|
941
|
+
* ⚠️ Unlike #6250, adopting this **does** add one dependency edge:
|
|
942
|
+
* `@objectstack/service-analytics` did not depend on `@objectstack/types`
|
|
943
|
+
* before #6615. It is acyclic by construction — `@objectstack/types` depends
|
|
944
|
+
* only on `@objectstack/spec`, which depends on nothing in-repo, so no package
|
|
945
|
+
* except `spec` itself can form a cycle by consuming it — and 25 of the repo's
|
|
946
|
+
* 73 packages (5 of 16 services) already carry the same edge. Recorded here
|
|
947
|
+
* rather than left for a reader to rediscover.
|
|
948
|
+
*/
|
|
949
|
+
/**
|
|
950
|
+
* Postgres' missing-COLUMN template, strictly. Returns the column name, or
|
|
951
|
+
* `undefined` when the message is not that phrase.
|
|
952
|
+
*
|
|
953
|
+
* Anchored to `column "%s" of relation "%s" does not exist` — the exact errmsg
|
|
954
|
+
* template Postgres emits for SQLSTATE 42703 on the write path
|
|
955
|
+
* (`INSERT` / `UPDATE` / `ALTER`). Both quotes are required because Postgres
|
|
956
|
+
* always emits them here, and requiring them is the safe direction of error for
|
|
957
|
+
* the two consumers that call this.
|
|
958
|
+
*
|
|
959
|
+
* Deliberately narrow in two further ways, both preserved verbatim from the
|
|
960
|
+
* open-coded copies this replaces:
|
|
961
|
+
*
|
|
962
|
+
* - the identifier is `[a-z0-9_]+` (case-insensitive), so a quoted identifier
|
|
963
|
+
* carrying a space or punctuation is NOT matched. Postgres can quote such
|
|
964
|
+
* names; the consumers accept the miss because a miss is the cheap direction.
|
|
965
|
+
* - the relation is `\S+` — quoted or bare, unparsed. This function answers
|
|
966
|
+
* "which COLUMN", never "which relation".
|
|
967
|
+
*
|
|
968
|
+
* The read-path phrasing `column "bogus" does not exist` is a different
|
|
969
|
+
* sentence with no relation in it, so it does not match — and it does not need
|
|
970
|
+
* to: it carries no missing-table substring, which is the whole hole this
|
|
971
|
+
* module exists for.
|
|
972
|
+
*/
|
|
973
|
+
declare function matchMissingColumnOfRelation(message: string): string | undefined;
|
|
974
|
+
/**
|
|
975
|
+
* The same quirk, **wider**: does this message talk about any sub-object of a
|
|
976
|
+
* relation, in any verdict?
|
|
977
|
+
*
|
|
978
|
+
* Drops all three of {@link matchMissingColumnOfRelation}'s anchors — the
|
|
979
|
+
* literal `column`, the `[a-z0-9_]+` identifier shape, and the trailing
|
|
980
|
+
* `does not exist` — so it also recognises `constraint "uq_x" of relation "y"
|
|
981
|
+
* does not exist` (42704), `column "x" of relation "y" already exists` (42701),
|
|
982
|
+
* and every other sub-object Postgres phrases this way.
|
|
983
|
+
*
|
|
984
|
+
* For **exclusion** callers only. A `true` here means "the relation is present,
|
|
985
|
+
* so whatever else this error is, it is not a missing table"; it does not mean
|
|
986
|
+
* the error is benign and it names nothing. Using it to extract would be a
|
|
987
|
+
* category error — there is no capture group precisely so that it cannot be.
|
|
988
|
+
*/
|
|
989
|
+
declare function isRelationSubObjectPhrase(message: string): boolean;
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Whether a thrown driver error is a unique/primary-key constraint violation.
|
|
993
|
+
*
|
|
994
|
+
* Reads all three channels in turn — `code`, `errno`, `message` — then one step
|
|
995
|
+
* down the `cause` chain, because pool and query-builder layers re-throw with
|
|
996
|
+
* the original attached. A plain string is judged on the message channel, so a
|
|
997
|
+
* caller that has already unwrapped `err.message` can pass it straight in.
|
|
998
|
+
*
|
|
999
|
+
* **Unrecognised is always `false`.** The default has to be "not a conflict":
|
|
1000
|
+
* a false positive relabels an unrelated failure as the client's fault (a 409
|
|
1001
|
+
* an SDK will not retry, pointing at a value that is fine), while a false
|
|
1002
|
+
* negative costs only the generic envelope that was the status quo.
|
|
1003
|
+
*
|
|
1004
|
+
* @param error - the thrown value, of any shape.
|
|
1005
|
+
*
|
|
1006
|
+
* @example
|
|
1007
|
+
* ```ts
|
|
1008
|
+
* catch (error) {
|
|
1009
|
+
* if (isUniqueViolationError(error)) return conflict(); // 409 UNIQUE_VIOLATION
|
|
1010
|
+
* throw error;
|
|
1011
|
+
* }
|
|
1012
|
+
* ```
|
|
1013
|
+
*/
|
|
1014
|
+
declare function isUniqueViolationError(error: unknown): boolean;
|
|
1015
|
+
/**
|
|
1016
|
+
* Which column a unique-constraint violation was raised on — or `undefined`
|
|
1017
|
+
* when the dialect did not determinably name one (#6544).
|
|
1018
|
+
*
|
|
1019
|
+
* ## The contract, and why it is this narrow
|
|
1020
|
+
*
|
|
1021
|
+
* **A value comes back only when the identifier the driver printed is
|
|
1022
|
+
* determinably a COLUMN.** When a dialect names an *index* instead — MySQL's
|
|
1023
|
+
* `Duplicate entry 'a@b.com' for key 'idx_email_unique'`, Postgres'
|
|
1024
|
+
* `violates unique constraint "sys_user_email_key"`, SQLite's
|
|
1025
|
+
* `UNIQUE constraint failed: index 'idx_lower_email'` — the answer is
|
|
1026
|
+
* `undefined`, never the index name.
|
|
1027
|
+
*
|
|
1028
|
+
* That is the maintainer's 2026-08-08 ruling on #6544, and the reasoning is the
|
|
1029
|
+
* caller's, not this module's: **an index name mistaken for a column is worse
|
|
1030
|
+
* than no answer at all.**
|
|
1031
|
+
*
|
|
1032
|
+
* - `@objectstack/rest`'s import runner renders this into a form field —
|
|
1033
|
+
* "A record with this `email` already exists." An index name there points
|
|
1034
|
+
* the user at a field that does not exist on the object, so they cannot act
|
|
1035
|
+
* on it; `undefined` degrades to generic copy, which is merely less helpful.
|
|
1036
|
+
* - #5495's autonumber-retry branch asks a yes/no question of the answer —
|
|
1037
|
+
* "is the conflicting column the autonumber field?" — and an index name
|
|
1038
|
+
* produces a *wrong retry decision*, not a vaguer one.
|
|
1039
|
+
*
|
|
1040
|
+
* ⛔ **The accepted cost: MySQL deployments usually get no column.** MySQL's
|
|
1041
|
+
* duplicate-entry message names the index and never the column, so there is
|
|
1042
|
+
* nothing here to read. That is deliberate. Do not "improve" this by deriving a
|
|
1043
|
+
* column from an index name (`idx_email_unique` → `email`, or MySQL 8's
|
|
1044
|
+
* `for key 'sys_user.email'` → `email`): index names are free-form, a
|
|
1045
|
+
* deployment's may match no column at all, and a plausible-looking wrong field
|
|
1046
|
+
* is exactly the failure this export exists to avoid. If MySQL must name
|
|
1047
|
+
* columns, the answer is a schema lookup of the index — a different, wider
|
|
1048
|
+
* contract — not a guess in this function.
|
|
1049
|
+
*
|
|
1050
|
+
* A **composite** key is `undefined` for the same reason: `Key (tenant_id,
|
|
1051
|
+
* email)=(…)` has no single offending column, and naming the first is the same
|
|
1052
|
+
* class of wrong answer.
|
|
1053
|
+
*
|
|
1054
|
+
* ## What it reads
|
|
1055
|
+
*
|
|
1056
|
+
* Gated on {@link isUniqueViolationError}, so a NOT NULL or FOREIGN KEY failure
|
|
1057
|
+
* can never reach the extraction — SQLite's `NOT NULL constraint failed: t.c`
|
|
1058
|
+
* shares its shape with the positive and is refused at the gate, not by the
|
|
1059
|
+
* patterns. Then `message`, then `detail` (node-postgres keeps its `DETAIL:`
|
|
1060
|
+
* line there), then one step down the `cause` chain, bounded exactly as the
|
|
1061
|
+
* predicate's walk is. A bare string is read as a message, so a caller holding
|
|
1062
|
+
* only `err.message` can pass it straight in.
|
|
1063
|
+
*
|
|
1064
|
+
* @param error - the thrown value, of any shape.
|
|
1065
|
+
* @returns the conflicting column, or `undefined` when none is determinable.
|
|
1066
|
+
*
|
|
1067
|
+
* @example
|
|
1068
|
+
* ```ts
|
|
1069
|
+
* const column = uniqueViolationColumn(error);
|
|
1070
|
+
* return column
|
|
1071
|
+
* ? `A record with this ${column} already exists.`
|
|
1072
|
+
* : 'A record with this value already exists.';
|
|
1073
|
+
* ```
|
|
1074
|
+
*/
|
|
1075
|
+
declare function uniqueViolationColumn(error: unknown): string | undefined;
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether a thrown driver error says the `ON CONFLICT` target it was given is
|
|
1079
|
+
* backed by no PRIMARY KEY or UNIQUE index.
|
|
1080
|
+
*
|
|
1081
|
+
* Reads the message channel, then one step at a time down the `cause` chain —
|
|
1082
|
+
* pool and query-builder layers re-throw with the original attached, and the
|
|
1083
|
+
* refusal this predicate gates keeps the raw error as its own `cause`. A plain
|
|
1084
|
+
* string is judged directly, so a caller that already unwrapped `err.message`
|
|
1085
|
+
* can pass it in.
|
|
1086
|
+
*
|
|
1087
|
+
* **Unrecognised is always `false`.** A false positive is the expensive
|
|
1088
|
+
* direction: it tells a caller to go add an index when the real failure was a
|
|
1089
|
+
* syntax error, a missing table, or — worst — a genuine unique violation on an
|
|
1090
|
+
* index that exists and works. A false negative costs only the raw error that
|
|
1091
|
+
* was the status quo before recognition existed.
|
|
1092
|
+
*
|
|
1093
|
+
* @param error - the thrown value, of any shape.
|
|
1094
|
+
*
|
|
1095
|
+
* @example
|
|
1096
|
+
* ```ts
|
|
1097
|
+
* catch (error) {
|
|
1098
|
+
* // ⚠️ NOT isUniqueViolationError — that is the opposite condition.
|
|
1099
|
+
* if (isUnbackedConflictTargetError(error)) throw refuseUnbackedConflictTarget(object, keys, error);
|
|
1100
|
+
* throw error;
|
|
1101
|
+
* }
|
|
1102
|
+
* ```
|
|
1103
|
+
*/
|
|
1104
|
+
declare function isUnbackedConflictTargetError(error: unknown): boolean;
|
|
1105
|
+
|
|
683
1106
|
/**
|
|
684
1107
|
* [ADR-0120 D5e] The `isolated`-posture install gate for `'global'` uniqueness.
|
|
685
1108
|
*
|
|
@@ -904,4 +1327,4 @@ interface RuntimePlugin {
|
|
|
904
1327
|
onStart?: (ctx: RuntimeContext) => void | Promise<void>;
|
|
905
1328
|
}
|
|
906
1329
|
|
|
907
|
-
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, keysetWalk, looksLikeInternalErrorLeak, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques };
|
|
1330
|
+
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, declaresServerFault, 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 };
|