@colixsystems/widget-sdk 0.86.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 +83 -7
- package/dist/contract.cjs +238 -21
- package/dist/contract.js +238 -21
- package/dist/hooks.js +177 -73
- package/dist/host.d.ts +90 -1
- package/dist/host.js +19 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +32 -0
- package/dist/linter.js +32 -0
- package/dist/theme-components.cjs +101 -5
- package/dist/theme-components.js +100 -4
- package/dist/toast-host.js +193 -0
- package/package.json +2 -2
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/host.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export type ThemeComponentStyle = Record<
|
|
|
25
25
|
>;
|
|
26
26
|
export type ThemeComponents = Record<string, ThemeComponentStyle>;
|
|
27
27
|
|
|
28
|
+
// REQ-THEME-ELEMENT: app-wide style values keyed by widget MANIFEST ID, then by
|
|
29
|
+
// that widget's own styleSchema field name. Values are structural (the
|
|
30
|
+
// authoritative type is the widget's own schema), so this mirrors
|
|
31
|
+
// ThemeComponentStyle rather than naming a closed vocabulary.
|
|
32
|
+
export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* REQ-THEME-15 host helper: validates a raw `themeConfig.components` blob down
|
|
30
36
|
* to `CONTRACT.themeComponents` — unknown scopes/tokens and malformed values are
|
|
@@ -33,6 +39,14 @@ export type ThemeComponents = Record<string, ThemeComponentStyle>;
|
|
|
33
39
|
*/
|
|
34
40
|
export function normaliseThemeComponents(raw: unknown): ThemeComponents;
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
|
|
44
|
+
* Structural only — the key space is the workspace's widget catalog, not the
|
|
45
|
+
* contract, so the authoritative field type is the widget's own styleSchema.
|
|
46
|
+
* Bounded by `CONTRACT.themeWidgetStyles`.
|
|
47
|
+
*/
|
|
48
|
+
export function normaliseWidgetStyles(raw: unknown): ThemeWidgetStyles;
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* REQ-THEME-15 host render-boundary helper: folds the theme's per-component
|
|
38
52
|
* tokens into a widget's props as `style` DEFAULTS, with the author's
|
|
@@ -41,6 +55,81 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
|
|
|
41
55
|
*/
|
|
42
56
|
export function applyThemeComponentStyle<T = Record<string, unknown>>(
|
|
43
57
|
manifestId: string,
|
|
44
|
-
theme:
|
|
58
|
+
theme:
|
|
59
|
+
| { components?: ThemeComponents; widgetStyles?: ThemeWidgetStyles }
|
|
60
|
+
| null
|
|
61
|
+
| undefined,
|
|
45
62
|
props: T,
|
|
63
|
+
styleSchema?: Record<string, unknown> | null,
|
|
46
64
|
): T;
|
|
65
|
+
|
|
66
|
+
// sc-4939 — the host half of `useToast()`. A widget only ever calls the hook;
|
|
67
|
+
// these are what a platform host puts behind `WidgetContext.toast`.
|
|
68
|
+
|
|
69
|
+
export type ToastKind = "success" | "error" | "warning" | "info";
|
|
70
|
+
|
|
71
|
+
export interface ToastPayload {
|
|
72
|
+
kind?: ToastKind | string;
|
|
73
|
+
message?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HostToast {
|
|
77
|
+
id: string;
|
|
78
|
+
kind: ToastKind;
|
|
79
|
+
message: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolved values a host paints one toast with. `elevation` is the React
|
|
83
|
+
* Native style object; `boxShadow` is the CSS string derived from it. */
|
|
84
|
+
export interface ToastTokens {
|
|
85
|
+
kind: ToastKind;
|
|
86
|
+
accent: string;
|
|
87
|
+
surface: string;
|
|
88
|
+
text: string;
|
|
89
|
+
border: string;
|
|
90
|
+
radius: number;
|
|
91
|
+
padding: number;
|
|
92
|
+
gap: number;
|
|
93
|
+
stackGap: number;
|
|
94
|
+
accentBarWidth: number;
|
|
95
|
+
fontFamily?: string;
|
|
96
|
+
fontSize: number;
|
|
97
|
+
elevation: Record<string, unknown>;
|
|
98
|
+
boxShadow: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ToastController {
|
|
102
|
+
/** Enqueue a toast. Returns its id, or null when the message is empty. */
|
|
103
|
+
show(payload: ToastPayload): string | null;
|
|
104
|
+
dismiss(id: string): void;
|
|
105
|
+
getToasts(): HostToast[];
|
|
106
|
+
/** Subscribe to the queue; returns an unsubscribe function. */
|
|
107
|
+
subscribe(listener: (toasts: HostToast[]) => void): () => void;
|
|
108
|
+
/** Clear every pending timer and listener. */
|
|
109
|
+
destroy(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ToastControllerOptions {
|
|
113
|
+
durationMs?: number;
|
|
114
|
+
maxVisible?: number;
|
|
115
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
116
|
+
clearTimer?: (handle: unknown) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const TOAST_DEFAULTS: { durationMs: number; maxVisible: number };
|
|
120
|
+
|
|
121
|
+
export function normalizeToastKind(kind: unknown): ToastKind;
|
|
122
|
+
|
|
123
|
+
export function resolveToastTokens(
|
|
124
|
+
theme: unknown,
|
|
125
|
+
kind: ToastKind | string,
|
|
126
|
+
): ToastTokens;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The host-side toast queue: newest-first stacking capped at `maxVisible`,
|
|
130
|
+
* auto-dismiss after `durationMs`, injectable timers. Shared by the web Player
|
|
131
|
+
* and the compiler's native WidgetHost so the two cannot drift.
|
|
132
|
+
*/
|
|
133
|
+
export function createToastController(
|
|
134
|
+
opts?: ToastControllerOptions,
|
|
135
|
+
): ToastController;
|
package/dist/host.js
CHANGED
|
@@ -18,5 +18,24 @@ export { resolveProps } from "./property-schema.js";
|
|
|
18
18
|
// hosts, so the Player and the Expo export cannot diverge.
|
|
19
19
|
export {
|
|
20
20
|
normaliseThemeComponents,
|
|
21
|
+
normaliseWidgetStyles,
|
|
21
22
|
applyThemeComponentStyle,
|
|
22
23
|
} from "./theme-components.js";
|
|
24
|
+
|
|
25
|
+
// REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
|
|
26
|
+
// surface — the page background and every container that carries its own fill.
|
|
27
|
+
// Host plumbing, deliberately off the author entry point: a widget reads the
|
|
28
|
+
// already-correct `useTheme().colors`, it never derives them itself.
|
|
29
|
+
export { deriveSurfaceTokens } from "./contract.js";
|
|
30
|
+
|
|
31
|
+
// REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
|
|
32
|
+
// auto-dismiss timing, and the themed values a toast is painted with. The
|
|
33
|
+
// widget-facing hook only forwards to `ctx.toast.showToast`; the host owns the
|
|
34
|
+
// surface, and sharing everything but the JSX is what keeps the web Player and
|
|
35
|
+
// the Expo export rendering the same notification (CLAUDE.md §8).
|
|
36
|
+
export {
|
|
37
|
+
createToastController,
|
|
38
|
+
resolveToastTokens,
|
|
39
|
+
normalizeToastKind,
|
|
40
|
+
TOAST_DEFAULTS,
|
|
41
|
+
} from "./toast-host.js";
|
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/index.js
CHANGED
package/dist/index.native.js
CHANGED
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.
|