@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 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.87.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**.
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: "(record) => Promise<Record> // rejects with DatastoreError",
828
- update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
829
- delete: "(id) => Promise<void> // rejects with DatastoreError",
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.61.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: "(record) => Promise<Record> // rejects with DatastoreError",
828
- update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
829
- delete: "(id) => Promise<void> // rejects with DatastoreError",
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.61.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
- // Surface a structured fieldErrors map when the server emitted one
987
- // (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
988
- let fieldErrors;
989
- if (
990
- err &&
991
- err.response &&
992
- err.response.data &&
993
- Array.isArray(err.response.data.errors)
994
- ) {
995
- const map = {};
996
- for (const entry of err.response.data.errors) {
997
- if (entry && typeof entry.field === "string") {
998
- map[entry.field] = entry.message || entry.code || "Invalid value";
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
- if (typeof bodyCode === "string" && bodyCode) {
1451
- // Preserve the server's stable code over the status-derived one when
1452
- // the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
1453
- // an inherit/template row).
1454
- code = bodyCode;
1455
- }
1456
- const message =
1457
- (typeof bodyMessage === "string" && bodyMessage) ||
1458
- (err && typeof err.message === "string"
1459
- ? err.message
1460
- : "Record permission call failed");
1461
- return new PermissionError(code, message, { status, cause: err });
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 (bodyCode === "INVITE_ONLY") code = "INVITE_ONLY";
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 = bodyCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2859
+ code = envelopeCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2758
2860
  }
2759
- const message =
2760
- (typeof bodyMessage === "string" && bodyMessage) ||
2761
- (err && typeof err.message === "string"
2762
- ? err.message
2763
- : "Directory call failed");
2764
- return new DirectoryError(code, message, { cause: err });
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?: { cause?: unknown },
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?: { status?: number; cause?: unknown },
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.87.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"