@colixsystems/widget-sdk 0.87.0 → 0.88.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/README.md +35 -1
- package/dist/contract.cjs +12 -9
- package/dist/contract.js +12 -9
- package/dist/hooks.js +177 -73
- package/dist/index.d.ts +27 -2
- package/dist/linter.cjs +32 -0
- package/dist/linter.js +32 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -61,7 +61,41 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
61
61
|
|
|
62
62
|
## Status
|
|
63
63
|
|
|
64
|
-
`v0.
|
|
64
|
+
`v0.88.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
65
|
+
|
|
66
|
+
### What's new in 0.88.0 (contract 1.62.0)
|
|
67
|
+
|
|
68
|
+
**A refused datastore / directory / permission call now reaches the widget as its real reason (sc-4986).**
|
|
69
|
+
|
|
70
|
+
- **The reason was being thrown away.** Each `@colixsystems/*-client` throws typed
|
|
71
|
+
errors carrying `.code` / `.status` / `.details` (the parsed envelope) and **no
|
|
72
|
+
`.response`** — but `toDatastoreError`, `toDirectoryError` and
|
|
73
|
+
`toPermissionError` read `err.response.*` only. Every typed client rejection
|
|
74
|
+
fell through every branch and arrived as `code: "INTERNAL"`, so a 403 the
|
|
75
|
+
workspace owner has to lift was indistinguishable from a dropped socket, and
|
|
76
|
+
`DatastoreError.fieldErrors` never populated at all. `toPaymentError` was fixed
|
|
77
|
+
for exactly this in 0.83.0; these three were not.
|
|
78
|
+
- **All three mappers now read both shapes**, preferring the envelope's own
|
|
79
|
+
`message` (the canonical `{ statusCode, message, code }` field — the old code
|
|
80
|
+
read a `.error` key the envelope has never carried). The documented `code`
|
|
81
|
+
vocabularies are unchanged, so a widget already branching on
|
|
82
|
+
`code === "FORBIDDEN"` starts working rather than having to change.
|
|
83
|
+
- **`DatastoreError` / `DirectoryError` / `PermissionError` gain `retryable`**
|
|
84
|
+
(and `status`). `retryable === false` for a refusal only the caller, the record
|
|
85
|
+
or the workspace can clear — 403 / 404 / 400 / 422 / 409 — and `true` for a
|
|
86
|
+
timeout, a rate limit, a 5xx or a dropped socket. Branch on it instead of
|
|
87
|
+
offering a blanket "try again". This is deliberately *not* the payments rule:
|
|
88
|
+
a 402 `DECLINED` card IS worth another attempt, so that contract differs.
|
|
89
|
+
- **`fieldErrors` works again** — a 400/422 carrying
|
|
90
|
+
`errors: [{ field, code, message }]` becomes the flat `{ field: message }` map
|
|
91
|
+
the type has always advertised, so a form can mark the offending input.
|
|
92
|
+
- **New soft lint rule `datastore-error-not-branched`** (severity `warning`,
|
|
93
|
+
never blocks a publish): a widget that writes with `useDatastoreMutation` but
|
|
94
|
+
never reads `retryable`, branches on `code ===`, or renders the error's own
|
|
95
|
+
`.message` is flagged, so the AI widget agent's repair loop closes the gap.
|
|
96
|
+
- `CONTRACT.version` → `1.62.0`: the three hooks' `returnShape` entries now name
|
|
97
|
+
the `{ code, message, retryable }` triple. No export or signature changed —
|
|
98
|
+
additive fields on three error classes.
|
|
65
99
|
|
|
66
100
|
### What's new in 0.87.0 (contract 1.61.1)
|
|
67
101
|
|
package/dist/contract.cjs
CHANGED
|
@@ -714,7 +714,7 @@ const HOOKS = [
|
|
|
714
714
|
signedAt: "string | null",
|
|
715
715
|
verdict: "{ valid, checks, content_status, ... } | null",
|
|
716
716
|
loading: "boolean",
|
|
717
|
-
error: "PermissionError | null",
|
|
717
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
718
718
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
719
719
|
refresh: "() => Promise<void>",
|
|
720
720
|
cancel: "() => Promise<void>",
|
|
@@ -824,9 +824,12 @@ const HOOKS = [
|
|
|
824
824
|
name: "useDatastoreMutation",
|
|
825
825
|
signature: "useDatastoreMutation(tableId)",
|
|
826
826
|
returnShape: {
|
|
827
|
-
create:
|
|
828
|
-
|
|
829
|
-
|
|
827
|
+
create:
|
|
828
|
+
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
829
|
+
update:
|
|
830
|
+
"(id, partial) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
831
|
+
delete:
|
|
832
|
+
"(id) => Promise<void> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
830
833
|
},
|
|
831
834
|
requiredContextSlice: ["datastore.records"],
|
|
832
835
|
scopes: ["datastore.write:*"],
|
|
@@ -942,7 +945,7 @@ const HOOKS = [
|
|
|
942
945
|
returnShape: {
|
|
943
946
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
944
947
|
loading: "boolean",
|
|
945
|
-
error: "DirectoryError | null",
|
|
948
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
946
949
|
refetch: "() => Promise<void>",
|
|
947
950
|
invite:
|
|
948
951
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -971,7 +974,7 @@ const HOOKS = [
|
|
|
971
974
|
returnShape: {
|
|
972
975
|
groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
|
|
973
976
|
loading: "boolean",
|
|
974
|
-
error: "DirectoryError | null",
|
|
977
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
975
978
|
refetch: "() => Promise<void>",
|
|
976
979
|
create:
|
|
977
980
|
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
@@ -1010,7 +1013,7 @@ const HOOKS = [
|
|
|
1010
1013
|
message: "string | null",
|
|
1011
1014
|
loading: "boolean",
|
|
1012
1015
|
statusLoading: "boolean",
|
|
1013
|
-
error: "DirectoryError | null",
|
|
1016
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
1014
1017
|
startLink: "() => Promise<{ order_ref, qr, auto_start_token, status }>",
|
|
1015
1018
|
refresh: "() => Promise<void>",
|
|
1016
1019
|
cancel: "() => Promise<void>",
|
|
@@ -1092,7 +1095,7 @@ const HOOKS = [
|
|
|
1092
1095
|
permissions:
|
|
1093
1096
|
"Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
|
|
1094
1097
|
loading: "boolean",
|
|
1095
|
-
error: "PermissionError | null",
|
|
1098
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
1096
1099
|
grant:
|
|
1097
1100
|
"({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
1098
1101
|
revoke:
|
|
@@ -2784,7 +2787,7 @@ const CONTRACT = deepFreeze({
|
|
|
2784
2787
|
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2785
2788
|
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2786
2789
|
// that sets none of it resolves exactly as before.
|
|
2787
|
-
version: "1.
|
|
2790
|
+
version: "1.62.0",
|
|
2788
2791
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2789
2792
|
hooks: HOOKS,
|
|
2790
2793
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -714,7 +714,7 @@ const HOOKS = [
|
|
|
714
714
|
signedAt: "string | null",
|
|
715
715
|
verdict: "{ valid, checks, content_status, ... } | null",
|
|
716
716
|
loading: "boolean",
|
|
717
|
-
error: "PermissionError | null",
|
|
717
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
718
718
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
719
719
|
refresh: "() => Promise<void>",
|
|
720
720
|
cancel: "() => Promise<void>",
|
|
@@ -824,9 +824,12 @@ const HOOKS = [
|
|
|
824
824
|
name: "useDatastoreMutation",
|
|
825
825
|
signature: "useDatastoreMutation(tableId)",
|
|
826
826
|
returnShape: {
|
|
827
|
-
create:
|
|
828
|
-
|
|
829
|
-
|
|
827
|
+
create:
|
|
828
|
+
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
829
|
+
update:
|
|
830
|
+
"(id, partial) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
831
|
+
delete:
|
|
832
|
+
"(id) => Promise<void> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
830
833
|
},
|
|
831
834
|
requiredContextSlice: ["datastore.records"],
|
|
832
835
|
scopes: ["datastore.write:*"],
|
|
@@ -942,7 +945,7 @@ const HOOKS = [
|
|
|
942
945
|
returnShape: {
|
|
943
946
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
944
947
|
loading: "boolean",
|
|
945
|
-
error: "DirectoryError | null",
|
|
948
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
946
949
|
refetch: "() => Promise<void>",
|
|
947
950
|
invite:
|
|
948
951
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -971,7 +974,7 @@ const HOOKS = [
|
|
|
971
974
|
returnShape: {
|
|
972
975
|
groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
|
|
973
976
|
loading: "boolean",
|
|
974
|
-
error: "DirectoryError | null",
|
|
977
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
975
978
|
refetch: "() => Promise<void>",
|
|
976
979
|
create:
|
|
977
980
|
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
@@ -1010,7 +1013,7 @@ const HOOKS = [
|
|
|
1010
1013
|
message: "string | null",
|
|
1011
1014
|
loading: "boolean",
|
|
1012
1015
|
statusLoading: "boolean",
|
|
1013
|
-
error: "DirectoryError | null",
|
|
1016
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
1014
1017
|
startLink: "() => Promise<{ order_ref, qr, auto_start_token, status }>",
|
|
1015
1018
|
refresh: "() => Promise<void>",
|
|
1016
1019
|
cancel: "() => Promise<void>",
|
|
@@ -1092,7 +1095,7 @@ const HOOKS = [
|
|
|
1092
1095
|
permissions:
|
|
1093
1096
|
"Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
|
|
1094
1097
|
loading: "boolean",
|
|
1095
|
-
error: "PermissionError | null",
|
|
1098
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
1096
1099
|
grant:
|
|
1097
1100
|
"({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
1098
1101
|
revoke:
|
|
@@ -2784,7 +2787,7 @@ const CONTRACT = deepFreeze({
|
|
|
2784
2787
|
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2785
2788
|
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2786
2789
|
// that sets none of it resolves exactly as before.
|
|
2787
|
-
version: "1.
|
|
2790
|
+
version: "1.62.0",
|
|
2788
2791
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2789
2792
|
hooks: HOOKS,
|
|
2790
2793
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -941,11 +941,29 @@ export function useGeolocation(options) {
|
|
|
941
941
|
* error message, populated when the datastore returned a structured
|
|
942
942
|
* 400/422 payload with `errors: [{ field, code }, ...]`.
|
|
943
943
|
*/
|
|
944
|
+
// sc-4986 — the datastore refusals retrying the same call can never clear.
|
|
945
|
+
// The default for a hand-constructed error; the mappers pass the status-derived
|
|
946
|
+
// answer explicitly (see isRetryableDomainStatus).
|
|
947
|
+
const NON_RETRYABLE_DATASTORE_CODES = new Set([
|
|
948
|
+
"CONSTRAINT_VIOLATION",
|
|
949
|
+
"FORBIDDEN",
|
|
950
|
+
"NOT_FOUND",
|
|
951
|
+
"VALIDATION",
|
|
952
|
+
]);
|
|
953
|
+
|
|
944
954
|
export class DatastoreError extends Error {
|
|
945
955
|
constructor(code, message, opts) {
|
|
946
956
|
super(message);
|
|
947
957
|
this.name = "DatastoreError";
|
|
948
958
|
this.code = code;
|
|
959
|
+
// sc-4986 — the HTTP status behind the code, when there was one. null for a
|
|
960
|
+
// transport failure or a locally-raised wiring error.
|
|
961
|
+
this.status =
|
|
962
|
+
opts && typeof opts.status === "number" ? opts.status : null;
|
|
963
|
+
this.retryable =
|
|
964
|
+
opts && opts.retryable !== undefined
|
|
965
|
+
? Boolean(opts.retryable)
|
|
966
|
+
: !NON_RETRYABLE_DATASTORE_CODES.has(code);
|
|
949
967
|
if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
|
|
950
968
|
this.fieldErrors = opts.fieldErrors;
|
|
951
969
|
}
|
|
@@ -960,50 +978,113 @@ export class DatastoreError extends Error {
|
|
|
960
978
|
* a DatastoreError with a stable `.code`. Reads `error.response.status` if
|
|
961
979
|
* present (axios shape) and falls back to inspecting the message string.
|
|
962
980
|
*/
|
|
981
|
+
/* ===========================================================================
|
|
982
|
+
* sc-4986 — reading a refusal's real reason off a rejected domain-client call
|
|
983
|
+
* ==========================================================================*/
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* The one envelope read the three data-domain mappers share.
|
|
987
|
+
*
|
|
988
|
+
* A rejected call arrives in one of TWO shapes and both have to be read, which
|
|
989
|
+
* is the whole bug this exists to fix:
|
|
990
|
+
*
|
|
991
|
+
* - the `@colixsystems/*-client` packages throw their own typed errors
|
|
992
|
+
* (`ForbiddenError`, `ValidationError`, …) carrying `.code` / `.status` /
|
|
993
|
+
* `.details` — the parsed envelope — and **no `.response`**;
|
|
994
|
+
* - the older host path throws an axios-shaped error with `.response`.
|
|
995
|
+
*
|
|
996
|
+
* Reading only `.response` (what these mappers used to do) meant every typed
|
|
997
|
+
* client rejection fell through every branch and became `INTERNAL`, so a 403
|
|
998
|
+
* was indistinguishable from a dropped socket.
|
|
999
|
+
*
|
|
1000
|
+
* `status` is the signal the code is derived from, never the client's own
|
|
1001
|
+
* `.code`: an axios transport failure carries `code: "ECONNABORTED"`, which is
|
|
1002
|
+
* not a domain code and must never be mistaken for one. The typed client errors
|
|
1003
|
+
* always set a numeric `.status`, and axios keeps its status on `.response`, so
|
|
1004
|
+
* the two reads together cover both shapes.
|
|
1005
|
+
*/
|
|
1006
|
+
function readDomainErrorEnvelope(err) {
|
|
1007
|
+
const response = (err && err.response) || null;
|
|
1008
|
+
const body = (response && response.data) || (err && err.details) || null;
|
|
1009
|
+
const status =
|
|
1010
|
+
response && typeof response.status === "number"
|
|
1011
|
+
? response.status
|
|
1012
|
+
: err && typeof err.status === "number" && err.status
|
|
1013
|
+
? err.status
|
|
1014
|
+
: null;
|
|
1015
|
+
// `message` is the canonical envelope's field (REQ-GEN-05, errorResponse.ts
|
|
1016
|
+
// emits `{ statusCode, message, ...extras }`); `error` is the ad-hoc key a
|
|
1017
|
+
// few older surfaces still emit. The envelope has never carried BOTH.
|
|
1018
|
+
const envelopeMessage = body && (body.message || body.error);
|
|
1019
|
+
return {
|
|
1020
|
+
status,
|
|
1021
|
+
code: body && typeof body.code === "string" && body.code ? body.code : null,
|
|
1022
|
+
message: typeof envelopeMessage === "string" ? envelopeMessage : null,
|
|
1023
|
+
fieldErrors: readEnvelopeFieldErrors(body),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* A flat `field -> message` map from an envelope's `errors: [{ field, code,
|
|
1029
|
+
* message }]` array (the shape `errorResponse(res, 4xx, msg, { errors })`
|
|
1030
|
+
* emits). Returns undefined when the body carries none, so the caller can leave
|
|
1031
|
+
* the property off the error entirely.
|
|
1032
|
+
*/
|
|
1033
|
+
function readEnvelopeFieldErrors(body) {
|
|
1034
|
+
if (!body || !Array.isArray(body.errors)) return undefined;
|
|
1035
|
+
const map = {};
|
|
1036
|
+
for (const entry of body.errors) {
|
|
1037
|
+
if (entry && typeof entry.field === "string") {
|
|
1038
|
+
map[entry.field] = entry.message || entry.code || "Invalid value";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return Object.keys(map).length > 0 ? map : undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Whether retrying the SAME data call could plausibly succeed.
|
|
1046
|
+
*
|
|
1047
|
+
* A timeout, a rate limit, a 5xx or a dropped socket is worth another attempt;
|
|
1048
|
+
* a refusal (403), a missing row (404), a bad body (400/422) or a conflict
|
|
1049
|
+
* (409) never clears until the caller, the record or the workspace changes.
|
|
1050
|
+
* An absent status is a transport failure, so it counts as transient.
|
|
1051
|
+
*
|
|
1052
|
+
* Deliberately NOT shared with `NON_RETRYABLE_PAYMENT_CODES`: a payment's 402
|
|
1053
|
+
* DECLINED IS worth another attempt with a different card, so the payments
|
|
1054
|
+
* contract genuinely differs here (CLAUDE.md §3 — the divergence is the point,
|
|
1055
|
+
* not an accident).
|
|
1056
|
+
*/
|
|
1057
|
+
function isRetryableDomainStatus(status) {
|
|
1058
|
+
if (status === null) return true;
|
|
1059
|
+
if (status === 408 || status === 429) return true;
|
|
1060
|
+
return status >= 500;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
963
1063
|
function toDatastoreError(err) {
|
|
1064
|
+
// An already-mapped SDK error passes straight through. NOTE: the datastore
|
|
1065
|
+
// CLIENT exports its own class of the same name, so a client rejection does
|
|
1066
|
+
// NOT match here — it is handled by the envelope read below, which is exactly
|
|
1067
|
+
// what used to be missing (sc-4986).
|
|
964
1068
|
if (err instanceof DatastoreError) return err;
|
|
965
|
-
const status =
|
|
966
|
-
err && err.response && typeof err.response.status === "number"
|
|
967
|
-
? err.response.status
|
|
968
|
-
: null;
|
|
969
|
-
const bodyMessage =
|
|
970
|
-
err &&
|
|
971
|
-
err.response &&
|
|
972
|
-
err.response.data &&
|
|
973
|
-
typeof err.response.data.error === "string"
|
|
974
|
-
? err.response.data.error
|
|
975
|
-
: null;
|
|
976
|
-
const fallbackMessage =
|
|
977
|
-
bodyMessage ||
|
|
978
|
-
(err && typeof err.message === "string"
|
|
979
|
-
? err.message
|
|
980
|
-
: "Datastore call failed");
|
|
1069
|
+
const { status, message, fieldErrors } = readDomainErrorEnvelope(err);
|
|
981
1070
|
let code = "INTERNAL";
|
|
982
1071
|
if (status === 400 || status === 422) code = "VALIDATION";
|
|
983
1072
|
else if (status === 409) code = "CONSTRAINT_VIOLATION";
|
|
984
1073
|
else if (status === 403) code = "FORBIDDEN";
|
|
985
1074
|
else if (status === 404) code = "NOT_FOUND";
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
if (Object.keys(map).length > 0) fieldErrors = map;
|
|
1002
|
-
}
|
|
1003
|
-
return new DatastoreError(code, fallbackMessage, {
|
|
1004
|
-
cause: err,
|
|
1005
|
-
fieldErrors,
|
|
1006
|
-
});
|
|
1075
|
+
return new DatastoreError(
|
|
1076
|
+
code,
|
|
1077
|
+
message ||
|
|
1078
|
+
(err && typeof err.message === "string"
|
|
1079
|
+
? err.message
|
|
1080
|
+
: "Datastore call failed"),
|
|
1081
|
+
{
|
|
1082
|
+
cause: err,
|
|
1083
|
+
status,
|
|
1084
|
+
retryable: isRetryableDomainStatus(status),
|
|
1085
|
+
fieldErrors,
|
|
1086
|
+
},
|
|
1087
|
+
);
|
|
1007
1088
|
}
|
|
1008
1089
|
|
|
1009
1090
|
// sc-1579 — consecutive renders with a different serialized query before
|
|
@@ -1422,43 +1503,54 @@ export function useDatastoreMutation(table) {
|
|
|
1422
1503
|
* - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
|
|
1423
1504
|
* - "INTERNAL" — anything else (network, 5xx).
|
|
1424
1505
|
*/
|
|
1506
|
+
// sc-4986 — the permission refusals retrying the same call can never clear.
|
|
1507
|
+
// This mapper's code set is OPEN (the server's own code wins), so the status is
|
|
1508
|
+
// the primary signal and this set only backstops a hand-constructed error.
|
|
1509
|
+
const NON_RETRYABLE_PERMISSION_CODES = new Set([
|
|
1510
|
+
"CONFLICT",
|
|
1511
|
+
"FORBIDDEN",
|
|
1512
|
+
"NOT_FOUND",
|
|
1513
|
+
"TEMPLATE_DERIVED",
|
|
1514
|
+
"VALIDATION",
|
|
1515
|
+
]);
|
|
1516
|
+
|
|
1425
1517
|
export class PermissionError extends Error {
|
|
1426
1518
|
constructor(code, message, opts) {
|
|
1427
1519
|
super(message);
|
|
1428
1520
|
this.name = "PermissionError";
|
|
1429
1521
|
this.code = code;
|
|
1430
1522
|
if (opts && opts.status !== undefined) this.status = opts.status;
|
|
1523
|
+
// sc-4986 — lets a widget tell "this will never work" from "try again".
|
|
1524
|
+
this.retryable =
|
|
1525
|
+
opts && opts.retryable !== undefined
|
|
1526
|
+
? Boolean(opts.retryable)
|
|
1527
|
+
: !NON_RETRYABLE_PERMISSION_CODES.has(code);
|
|
1431
1528
|
if (opts && opts.cause) this.cause = opts.cause;
|
|
1432
1529
|
}
|
|
1433
1530
|
}
|
|
1434
1531
|
|
|
1435
1532
|
function toPermissionError(err) {
|
|
1533
|
+
// See the note in toDatastoreError — a client rejection is read from the
|
|
1534
|
+
// envelope below rather than matching this instanceof.
|
|
1436
1535
|
if (err instanceof PermissionError) return err;
|
|
1437
|
-
const status =
|
|
1438
|
-
err && err.response && typeof err.response.status === "number"
|
|
1439
|
-
? err.response.status
|
|
1440
|
-
: null;
|
|
1441
|
-
const bodyCode =
|
|
1442
|
-
err && err.response && err.response.data && err.response.data.code;
|
|
1443
|
-
const bodyMessage =
|
|
1444
|
-
err && err.response && err.response.data && err.response.data.error;
|
|
1536
|
+
const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
|
|
1445
1537
|
let code = "INTERNAL";
|
|
1446
1538
|
if (status === 403) code = "FORBIDDEN";
|
|
1447
1539
|
else if (status === 404) code = "NOT_FOUND";
|
|
1448
1540
|
else if (status === 409) code = "CONFLICT";
|
|
1449
1541
|
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1542
|
+
// Preserve the server's stable code over the status-derived one when the
|
|
1543
|
+
// server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of an
|
|
1544
|
+
// inherit/template row) — this mapper's documented code set is open.
|
|
1545
|
+
if (envelopeCode) code = envelopeCode;
|
|
1546
|
+
return new PermissionError(
|
|
1547
|
+
code,
|
|
1548
|
+
message ||
|
|
1549
|
+
(err && typeof err.message === "string"
|
|
1550
|
+
? err.message
|
|
1551
|
+
: "Record permission call failed"),
|
|
1552
|
+
{ cause: err, status, retryable: isRetryableDomainStatus(status) },
|
|
1553
|
+
);
|
|
1462
1554
|
}
|
|
1463
1555
|
|
|
1464
1556
|
const _NOOP_PERMISSIONS_RESULT = Object.freeze({
|
|
@@ -2727,41 +2819,53 @@ export function useFolderPermissions(folderId, options = {}) {
|
|
|
2727
2819
|
* is invite-only and the email is not on the list.
|
|
2728
2820
|
* - "INTERNAL" — anything else (network, 5xx).
|
|
2729
2821
|
*/
|
|
2822
|
+
// sc-4986 — the directory refusals retrying the same call can never clear.
|
|
2823
|
+
const NON_RETRYABLE_DIRECTORY_CODES = new Set([
|
|
2824
|
+
"FORBIDDEN",
|
|
2825
|
+
"INVITE_ONLY",
|
|
2826
|
+
"NOT_FOUND",
|
|
2827
|
+
"VALIDATION",
|
|
2828
|
+
]);
|
|
2829
|
+
|
|
2730
2830
|
export class DirectoryError extends Error {
|
|
2731
2831
|
constructor(code, message, opts) {
|
|
2732
2832
|
super(message);
|
|
2733
2833
|
this.name = "DirectoryError";
|
|
2734
2834
|
this.code = code;
|
|
2835
|
+
// sc-4986 — the HTTP status behind the code, when there was one.
|
|
2836
|
+
this.status =
|
|
2837
|
+
opts && typeof opts.status === "number" ? opts.status : null;
|
|
2838
|
+
this.retryable =
|
|
2839
|
+
opts && opts.retryable !== undefined
|
|
2840
|
+
? Boolean(opts.retryable)
|
|
2841
|
+
: !NON_RETRYABLE_DIRECTORY_CODES.has(code);
|
|
2735
2842
|
if (opts && opts.cause) this.cause = opts.cause;
|
|
2736
2843
|
}
|
|
2737
2844
|
}
|
|
2738
2845
|
|
|
2739
2846
|
function toDirectoryError(err) {
|
|
2847
|
+
// See the note in toDatastoreError: the directory CLIENT has a same-named
|
|
2848
|
+
// class, so a client rejection is read from the envelope below, not here.
|
|
2740
2849
|
if (err instanceof DirectoryError) return err;
|
|
2741
|
-
const status =
|
|
2742
|
-
err && err.response && typeof err.response.status === "number"
|
|
2743
|
-
? err.response.status
|
|
2744
|
-
: null;
|
|
2745
|
-
const bodyCode =
|
|
2746
|
-
err && err.response && err.response.data && err.response.data.code;
|
|
2747
|
-
const bodyMessage =
|
|
2748
|
-
err && err.response && err.response.data && err.response.data.error;
|
|
2850
|
+
const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
|
|
2749
2851
|
let code = "INTERNAL";
|
|
2750
|
-
if (
|
|
2852
|
+
if (envelopeCode === "INVITE_ONLY") code = "INVITE_ONLY";
|
|
2751
2853
|
else if (status === 403) code = "FORBIDDEN";
|
|
2752
2854
|
else if (status === 404) code = "NOT_FOUND";
|
|
2753
2855
|
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
2754
2856
|
else if (status === 409) {
|
|
2755
2857
|
// 409 is invite-only on the invite endpoints; treat the rest as
|
|
2756
2858
|
// validation conflicts (duplicate email, etc.).
|
|
2757
|
-
code =
|
|
2859
|
+
code = envelopeCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
|
|
2758
2860
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2861
|
+
return new DirectoryError(
|
|
2862
|
+
code,
|
|
2863
|
+
message ||
|
|
2864
|
+
(err && typeof err.message === "string"
|
|
2865
|
+
? err.message
|
|
2866
|
+
: "Directory call failed"),
|
|
2867
|
+
{ cause: err, status, retryable: isRetryableDomainStatus(status) },
|
|
2868
|
+
);
|
|
2765
2869
|
}
|
|
2766
2870
|
|
|
2767
2871
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1275,11 +1275,22 @@ export class DatastoreError extends Error {
|
|
|
1275
1275
|
| "NOT_FOUND"
|
|
1276
1276
|
| "INTERNAL";
|
|
1277
1277
|
fieldErrors?: Record<string, string>;
|
|
1278
|
+
/** sc-4986 — the HTTP status behind the code; null for a transport failure. */
|
|
1279
|
+
status: number | null;
|
|
1280
|
+
/**
|
|
1281
|
+
* sc-4986 — whether retrying the SAME call could plausibly succeed. False
|
|
1282
|
+
* for a refusal only the caller, the record or the workspace can clear
|
|
1283
|
+
* (403/404/400/422/409); true for a timeout, a rate limit, a 5xx or a
|
|
1284
|
+
* dropped socket. Branch on this instead of offering a blanket retry.
|
|
1285
|
+
*/
|
|
1286
|
+
retryable: boolean;
|
|
1278
1287
|
constructor(
|
|
1279
1288
|
code: DatastoreError["code"],
|
|
1280
1289
|
message: string,
|
|
1281
1290
|
opts?: {
|
|
1282
1291
|
fieldErrors?: Record<string, string>;
|
|
1292
|
+
status?: number | null;
|
|
1293
|
+
retryable?: boolean;
|
|
1283
1294
|
cause?: unknown;
|
|
1284
1295
|
},
|
|
1285
1296
|
);
|
|
@@ -1297,10 +1308,18 @@ export class DirectoryError extends Error {
|
|
|
1297
1308
|
| "NOT_FOUND"
|
|
1298
1309
|
| "INVITE_ONLY"
|
|
1299
1310
|
| "INTERNAL";
|
|
1311
|
+
/** sc-4986 — the HTTP status behind the code; null for a transport failure. */
|
|
1312
|
+
status: number | null;
|
|
1313
|
+
/** sc-4986 — whether retrying the SAME call could plausibly succeed. */
|
|
1314
|
+
retryable: boolean;
|
|
1300
1315
|
constructor(
|
|
1301
1316
|
code: DirectoryError["code"],
|
|
1302
1317
|
message: string,
|
|
1303
|
-
opts?: {
|
|
1318
|
+
opts?: {
|
|
1319
|
+
status?: number | null;
|
|
1320
|
+
retryable?: boolean;
|
|
1321
|
+
cause?: unknown;
|
|
1322
|
+
},
|
|
1304
1323
|
);
|
|
1305
1324
|
}
|
|
1306
1325
|
|
|
@@ -1620,10 +1639,16 @@ export class PermissionError extends Error {
|
|
|
1620
1639
|
| "INTERNAL"
|
|
1621
1640
|
| string;
|
|
1622
1641
|
status?: number;
|
|
1642
|
+
/** sc-4986 — whether retrying the SAME call could plausibly succeed. */
|
|
1643
|
+
retryable: boolean;
|
|
1623
1644
|
constructor(
|
|
1624
1645
|
code: PermissionError["code"],
|
|
1625
1646
|
message: string,
|
|
1626
|
-
opts?: {
|
|
1647
|
+
opts?: {
|
|
1648
|
+
status?: number | null;
|
|
1649
|
+
retryable?: boolean;
|
|
1650
|
+
cause?: unknown;
|
|
1651
|
+
},
|
|
1627
1652
|
);
|
|
1628
1653
|
}
|
|
1629
1654
|
|
package/dist/linter.cjs
CHANGED
|
@@ -845,6 +845,37 @@ function _paymentErrorHandlingRules(source) {
|
|
|
845
845
|
];
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
+
// sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
|
|
849
|
+
// A write can be refused for reasons a retry never clears (the table's grants,
|
|
850
|
+
// a validation failure, a row that is gone), and the server sends a user-safe
|
|
851
|
+
// sentence saying which. A widget that collapses every rejection into one
|
|
852
|
+
// generic "something went wrong" throws that sentence away and leaves the user
|
|
853
|
+
// pressing the button again. Reading `DatastoreError.retryable`, branching on
|
|
854
|
+
// `code ===`, or rendering the error's own `.message` all satisfy it; strings
|
|
855
|
+
// and comments are blanked so prose about errors never does.
|
|
856
|
+
function _datastoreErrorHandlingRules(source) {
|
|
857
|
+
const code = _stripNonCode(source);
|
|
858
|
+
const call = /\buseDatastoreMutation\s*\(/.exec(code);
|
|
859
|
+
if (!call) return [];
|
|
860
|
+
if (/\bretryable\b/.test(code)) return [];
|
|
861
|
+
if (/\bcode\s*===/.test(code)) return [];
|
|
862
|
+
if (/\.message\b/.test(code)) return [];
|
|
863
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
864
|
+
return [
|
|
865
|
+
{
|
|
866
|
+
rule: "datastore-error-not-branched",
|
|
867
|
+
severity: "warning",
|
|
868
|
+
label:
|
|
869
|
+
`writes with useDatastoreMutation() but never reports why a write ` +
|
|
870
|
+
`failed — read DatastoreError.retryable (or err.message) and show the ` +
|
|
871
|
+
`server's reason, so a refusal the user cannot clear is not offered ` +
|
|
872
|
+
`as "try again".`,
|
|
873
|
+
line,
|
|
874
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
875
|
+
},
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
|
|
848
879
|
function _imagePercentHeightRules(source) {
|
|
849
880
|
const findings = [];
|
|
850
881
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
@@ -1012,6 +1043,7 @@ function lintSource(source, options) {
|
|
|
1012
1043
|
findings.push(..._paymentCurrencyRules(source));
|
|
1013
1044
|
findings.push(..._hardcodedCurrencyLabelRules(source));
|
|
1014
1045
|
findings.push(..._paymentErrorHandlingRules(source));
|
|
1046
|
+
findings.push(..._datastoreErrorHandlingRules(source));
|
|
1015
1047
|
findings.push(
|
|
1016
1048
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
1017
1049
|
...f,
|
package/dist/linter.js
CHANGED
|
@@ -977,6 +977,37 @@ function _paymentErrorHandlingRules(source) {
|
|
|
977
977
|
];
|
|
978
978
|
}
|
|
979
979
|
|
|
980
|
+
// sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
|
|
981
|
+
// A write can be refused for reasons a retry never clears (the table's grants,
|
|
982
|
+
// a validation failure, a row that is gone), and the server sends a user-safe
|
|
983
|
+
// sentence saying which. A widget that collapses every rejection into one
|
|
984
|
+
// generic "something went wrong" throws that sentence away and leaves the user
|
|
985
|
+
// pressing the button again. Reading `DatastoreError.retryable`, branching on
|
|
986
|
+
// `code ===`, or rendering the error's own `.message` all satisfy it; strings
|
|
987
|
+
// and comments are blanked so prose about errors never does.
|
|
988
|
+
function _datastoreErrorHandlingRules(source) {
|
|
989
|
+
const code = _stripNonCode(source);
|
|
990
|
+
const call = /\buseDatastoreMutation\s*\(/.exec(code);
|
|
991
|
+
if (!call) return [];
|
|
992
|
+
if (/\bretryable\b/.test(code)) return [];
|
|
993
|
+
if (/\bcode\s*===/.test(code)) return [];
|
|
994
|
+
if (/\.message\b/.test(code)) return [];
|
|
995
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
996
|
+
return [
|
|
997
|
+
{
|
|
998
|
+
rule: "datastore-error-not-branched",
|
|
999
|
+
severity: "warning",
|
|
1000
|
+
label:
|
|
1001
|
+
`writes with useDatastoreMutation() but never reports why a write ` +
|
|
1002
|
+
`failed — read DatastoreError.retryable (or err.message) and show the ` +
|
|
1003
|
+
`server's reason, so a refusal the user cannot clear is not offered ` +
|
|
1004
|
+
`as "try again".`,
|
|
1005
|
+
line,
|
|
1006
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
1007
|
+
},
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
|
|
980
1011
|
function _imagePercentHeightRules(source) {
|
|
981
1012
|
const findings = [];
|
|
982
1013
|
// Comments are blanked (string contents kept) so a commented-out example —
|
|
@@ -1174,6 +1205,7 @@ export function lintSource(source, options) {
|
|
|
1174
1205
|
findings.push(..._paymentCurrencyRules(source));
|
|
1175
1206
|
findings.push(..._hardcodedCurrencyLabelRules(source));
|
|
1176
1207
|
findings.push(..._paymentErrorHandlingRules(source));
|
|
1208
|
+
findings.push(..._datastoreErrorHandlingRules(source));
|
|
1177
1209
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
1178
1210
|
// line-by-line scan so banned-identifier findings stay first in the
|
|
1179
1211
|
// output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|